This is an automated email from the ASF dual-hosted git repository.
warren pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git
The following commit(s) were added to refs/heads/main by this push:
new 7ac265f6f feat: add aws iam identity center integration for the q dev
plugin (#8477)
7ac265f6f is described below
commit 7ac265f6f579bccaf37c348c6d02d62885a85256
Author: DiscreteTom <[email protected]>
AuthorDate: Wed Jun 25 15:13:42 2025 +0800
feat: add aws iam identity center integration for the q dev plugin (#8477)
* chore: add tasks
* feat: add database migration for user display name fields
- Add migration script to add display_name field to user tables
- Update migration registry to include new migration
- Update TODO.md to track implementation progress
Tasks completed:
- Task 1.1: Create Database Migration
- Task 1.2: Update Migration Registry
* refactor: simplify migration to use raw SQL approach
- Replace AutoMigrate with direct SQL ALTER TABLE statements
- Remove complex struct definitions with all existing fields
- Use cleaner, safer approach following existing migration patterns
- Only add display_name VARCHAR(255) to both user tables
* fix: correct migration date to 2025-06-23
- Rename migration file from 20240623 to 20250623
- Update version number to 20250623000001
- Align with current date (2025-06-23)
* feat: update connection model with required IAM Identity Center fields
- Add IdentityStoreId and IdentityStoreRegion as required fields
- Update comments to clarify both fields are required
- Add comprehensive tests for required field validation
- Remove optional field test case (fields are now required)
- Ensure sanitization preserves Identity Center configuration
Task completed: Task 1.3: Update Connection Model
* feat: add DisplayName field to QDevUserData model
- Add DisplayName field with VARCHAR(255) type and JSON serialization
- Create comprehensive tests covering various display name scenarios
- Test display name with actual names, fallback UUIDs, and empty values
- Verify all existing fields remain functional
- Update TODO.md progress tracking
Task completed: Task 1.4: Update User Data Model
* feat: add DisplayName field to QDevUserMetrics model
- Add DisplayName field with VARCHAR(255) type and JSON serialization
- Create comprehensive tests covering various display name scenarios
- Test display name with actual names, fallback UUIDs, and empty values
- Verify all existing aggregated and average metrics remain functional
- Ensure proper integration with all 15 total metrics and 7 average metrics
- Update TODO.md progress tracking
Task completed: Task 1.5: Update User Metrics Model
Phase 1 (Database Schema and Model Updates) is now complete!
* feat: implement AWS Identity Center client for user display name
resolution
- Create QDevIdentityClient with AWS Identity Store integration
- Add IdentityStoreAPI interface for testability and mocking
- Implement NewQDevIdentityClient factory function with proper AWS session
setup
- Add ResolveUserDisplayName method with fallback to UUID on errors
- Handle empty/missing Identity Store configuration gracefully
- Create comprehensive test suite with 8 test cases covering:
- Successful client creation and display name resolution
- Empty configuration handling (returns nil client)
- API error handling with UUID fallback
- Empty/nil display name handling
- Input validation and parameter verification
- Use mock-based testing for AWS API interactions
Task completed: Task 2.1: Create Identity Client
Phase 2 (Identity Center Client Implementation) is now complete!
* feat: add IdentityClient field to QDevTaskData structure
- Add IdentityClient field to QDevTaskData for Identity Center integration
- Create comprehensive test suite with 5 test cases covering:
- Task data with Identity Client configured
- Task data without Identity Client (nil handling)
- Complete field initialization and access
- Empty struct initialization
- Partial initialization scenarios
- Ensure backward compatibility with existing S3Client and Options fields
- All 13 tests pass (8 identity + 5 task data tests)
- Update TODO.md progress tracking
Task completed: Task 3.1: Update Task Data Structure
* feat: integrate Identity Client into plugin PrepareTaskData method
- Update PrepareTaskData to create and configure Identity Client
- Add graceful error handling for Identity Client creation failures
- Log warnings when Identity Client cannot be created but continue execution
- Ensure backward compatibility when Identity Store is not configured
- Create comprehensive test suite with 3 test cases covering:
- Basic plugin method functionality verification
- Task data structure with Identity Client integration
- Task data structure without Identity Client (nil handling)
- All tests pass successfully with proper error handling
- Update TODO.md progress tracking
Task completed: Task 3.2: Update Plugin Implementation
The plugin now properly initializes both S3Client and IdentityClient
in the task data, making Identity Center functionality available
throughout the data processing pipeline.
* feat: integrate display name resolution into S3 Data Extractor
- Add UserDisplayNameResolver interface for testable display name resolution
- Create createUserDataWithDisplayName function with Identity Client
integration
- Update processCSVData to use Identity Client from task data
- Add resolveDisplayName helper with graceful error handling and fallback
- Implement comprehensive test suite with 7 test cases covering:
- Successful display name resolution from Identity Center
- Fallback to UUID when Identity Center API fails
- Graceful handling when no Identity Client available
- Empty display name fallback to UUID
- Complete field mapping with all Q Developer metrics
- Error handling for missing required fields (UserId, Date)
- All 20 tests pass (8 identity + 7 extractor + 5 task data)
- Maintain backward compatibility with existing CSV processing
- Update TODO.md progress tracking
Task completed: Task 3.3: Update S3 Data Extractor
The S3 Data Extractor now resolves user display names during CSV processing,
storing both UUID and human-readable names in the database for enhanced
dashboard usability.
* feat: integrate display name support into User Metrics Converter
- Create UserMetricsAggregationWithDisplayName struct with display name
field
- Add ToUserMetrics method for clean conversion to QDevUserMetrics model
- Update ConvertQDevUserMetrics to use enhanced aggregation with display
names
- Add resolveDisplayNameForAggregation helper with graceful error handling
- Implement smart display name resolution strategy:
- Use existing display name from user data if available
- Fall back to Identity Client resolution for new users
- Fall back to UUID if Identity Client fails or unavailable
- Create comprehensive test suite with 8 test cases covering:
- Single user aggregation with display name
- Fallback display name scenarios (UUID when resolution fails)
- Acceptance rate calculation with display names
- Zero acceptance rate handling
- Complete field aggregation with all 15 total + 7 average metrics
- Display name resolution success, failure, and no-client scenarios
- All 28 tests pass (8 identity + 7 extractor + 5 task data + 8 converter)
- Maintain backward compatibility with existing aggregation logic
- Update TODO.md progress tracking
Task completed: Task 3.4: Update User Metrics Converter
Phase 3 (Enhanced Data Extraction) is now complete!
The User Metrics Converter now preserves and aggregates display names
alongside all Q Developer metrics, enabling human-readable dashboards
with comprehensive user analytics.
* feat: enhance Connection API with Identity Store validation
- Add comprehensive validateConnection function with Identity Store
validation
- Update PostConnections to validate Identity Store fields as required
- Update PatchConnection to validate updated connection parameters
- Implement validation for all required fields:
- AWS credentials (AccessKeyId, SecretAccessKey, Region, Bucket)
- Identity Store fields (IdentityStoreId, IdentityStoreRegion) - now
required
- Rate limit validation with default value assignment
- Create comprehensive test suite with 11 test cases covering:
- Successful validation with all required fields
- Missing field validation for each required parameter
- Invalid rate limit handling with error messages
- Default rate limit assignment (0 → 20000)
- JSON serialization/deserialization with new fields
- Connection sanitization preserving Identity Store fields
- All 11 API tests pass successfully
- Maintain backward compatibility with existing API structure
- Update TODO.md progress tracking
Task completed: Task 4.1: Update Connection API
Phase 4 (API and Configuration Updates) is now complete!
The Connection API now enforces Identity Store configuration as required,
ensuring all connections have the necessary credentials for user display
name resolution while providing clear validation error messages.
* docs: minimal update for Identity Center integration
- Add Identity Center feature to plugin description
- Add IAM Identity Center Store ID and Region to configuration items
- Update connection creation example with identityStoreId and
identityStoreRegion
- Add placeholder descriptions for new Identity Center fields
- Update data flow description to mention display name resolution
- Add note about display_name fields in data tables
- Mark Task 5.2 as completed in TODO.md
Task completed: Task 5.2: Update Documentation (minimal changes)
The documentation now includes the essential Identity Center
configuration information without extensive rewrites.
* docs: add Identity Center requirements to deployment guide
- Add Q Developer Plugin Configuration section to deployment guide
- Document required AWS permissions for S3 and Identity Center
- List required configuration fields including Identity Center Store ID
- Minimal addition to existing deployment documentation
The deployment guide now includes essential AWS setup information
for the Identity Center integration without major restructuring.
* fix: add missing Identity Center fields to migration script
- Add identity_store_id and identity_store_region columns to
_tool_q_dev_connections table
- Update migration script to include both Identity Center and display name
fields
- Fix database schema mismatch causing 'Unknown column' error
- Update migration name to reflect all changes
This resolves the MySQL error: Unknown column 'identity_store_id' in 'field
list'
* fix: make migration script idempotent with IF NOT EXISTS checks
- Add 'IF NOT EXISTS' clause to all ALTER TABLE ADD COLUMN statements
- Prevent 'Duplicate column name' errors when migration runs multiple times
- Make migration script safe to re-run without errors
- Fix MySQL Error 1060 (42S21): Duplicate column name 'display_name'
The migration script is now idempotent and can be safely executed
multiple times without causing duplicate column errors.
* fix: simplify migration script to ignore duplicate column errors
- Remove unsupported 'IF NOT EXISTS' syntax (MySQL doesn't support it in
ALTER TABLE)
- Use simple approach: execute ALTER TABLE statements and ignore errors
- MySQL error 1060 (duplicate column) will be ignored, allowing migration
to succeed
- Fix MySQL Error 1064 (42000): SQL syntax error with IF NOT EXISTS
The migration script now uses a simpler approach that works with MySQL
by ignoring duplicate column errors instead of checking existence first.
* fix: update Grafana dashboards to show display names instead of user IDs
- Replace 'user_id' with 'COALESCE(display_name, user_id) as User' in all Q
Developer dashboards
- Update QDevUserData.json to show human-readable names in user activity
charts
- Update QDevUserMetrics.json to show display names in user metrics tables
- Add GROUP BY display_name clause where needed for proper aggregation
- Dashboards now show 'John Doe' instead of 'uuid-123-456' for better
readability
The Grafana dashboards now display human-readable user names while
falling back to UUIDs when display names are not available.
* chore: clean up intermediate development files
- Remove AmazonQ.md (development notes)
- Remove TODO.md (task tracking completed)
- Remove plan.md (planning document no longer needed)
- Remove tasks.md (task breakdown completed)
All development tasks are complete and these intermediate files
are no longer needed. The main README.md and deployment guide
contain all necessary documentation.
* fix: resolve linting issues in q_dev plugin
- Fix errcheck issues in migration script by explicitly ignoring db.Exec
errors
- Fix gofmt formatting issues across multiple files
- Ensure code adheres to project's golangci-lint standards
Files modified:
- backend/plugins/q_dev/api/connection.go
- backend/plugins/q_dev/models/connection.go
-
backend/plugins/q_dev/models/migrationscripts/20250623_add_display_name_fields.go
- backend/plugins/q_dev/tasks/identity_client.go
---
backend/plugins/q_dev/Q_DEV_deploy_guide.md | 13 ++
backend/plugins/q_dev/README.md | 13 +-
backend/plugins/q_dev/api/connection.go | 52 ++++-
backend/plugins/q_dev/api/connection_test.go | 244 +++++++++++++++++++++
backend/plugins/q_dev/impl/impl.go | 14 +-
backend/plugins/q_dev/impl/impl_test.go | 92 ++++++++
backend/plugins/q_dev/models/connection.go | 8 +-
backend/plugins/q_dev/models/connection_test.go | 111 ++++++++++
.../20250623_add_display_name_fields.go | 55 +++++
.../q_dev/models/migrationscripts/register.go | 1 +
backend/plugins/q_dev/models/user_data.go | 1 +
backend/plugins/q_dev/models/user_data_test.go | 118 ++++++++++
backend/plugins/q_dev/models/user_metrics.go | 1 +
backend/plugins/q_dev/models/user_metrics_test.go | 155 +++++++++++++
backend/plugins/q_dev/tasks/identity_client.go | 91 ++++++++
.../plugins/q_dev/tasks/identity_client_test.go | 195 ++++++++++++++++
backend/plugins/q_dev/tasks/s3_data_extractor.go | 42 +++-
.../plugins/q_dev/tasks/s3_data_extractor_test.go | 201 +++++++++++++++++
backend/plugins/q_dev/tasks/task_data.go | 5 +-
backend/plugins/q_dev/tasks/task_data_test.go | 126 +++++++++++
.../plugins/q_dev/tasks/user_metrics_converter.go | 147 ++++++++-----
.../q_dev/tasks/user_metrics_converter_test.go | 212 ++++++++++++++++++
grafana/dashboards/QDevUserData.json | 2 +-
grafana/dashboards/QDevUserMetrics.json | 8 +-
24 files changed, 1834 insertions(+), 73 deletions(-)
diff --git a/backend/plugins/q_dev/Q_DEV_deploy_guide.md
b/backend/plugins/q_dev/Q_DEV_deploy_guide.md
index 218c55c6e..5d9791754 100644
--- a/backend/plugins/q_dev/Q_DEV_deploy_guide.md
+++ b/backend/plugins/q_dev/Q_DEV_deploy_guide.md
@@ -59,6 +59,19 @@ Update the following variables in the `.env` file:
- `DB_URL`: Replace `mysql:3306` with `127.0.0.1:3306`
- `DISABLED_REMOTE_PLUGINS`: Set to `True`
+### Q Developer Plugin Configuration
+The Q Developer plugin requires AWS credentials with access to both S3 and IAM
Identity Center:
+
+**Required AWS Permissions:**
+- S3: `s3:GetObject`, `s3:ListBucket` for the Q Developer data bucket
+- Identity Center: `identitystore:DescribeUser` for user display name
resolution
+
+**Required Configuration Fields:**
+- AWS Access Key ID and Secret Access Key
+- S3 bucket name and region
+- IAM Identity Center Store ID (format: `d-xxxxxxxxxx`)
+- IAM Identity Center region
+
### Start MySQL and Grafana Containers
Make sure the Docker daemon is running before this step.
diff --git a/backend/plugins/q_dev/README.md b/backend/plugins/q_dev/README.md
index 331359083..7d5cc4e7c 100644
--- a/backend/plugins/q_dev/README.md
+++ b/backend/plugins/q_dev/README.md
@@ -17,12 +17,13 @@ limitations under the License.
# Q Developer Plugin
-This plugin is used to retrieve AWS Q Developer usage data from AWS S3, and
process and analyze it.
+This plugin is used to retrieve AWS Q Developer usage data from AWS S3,
process and analyze it, and resolve user display names through AWS IAM Identity
Center.
## Features
- Retrieve CSV files from a specified prefix in AWS S3
- Parse user usage data from CSV files
+- Resolve user UUIDs to human-readable display names via AWS IAM Identity
Center
- Aggregate data by user and calculate various metrics
## Configuration
@@ -34,6 +35,8 @@ Configuration items include:
3. AWS Region
4. S3 Bucket Name
5. Rate Limit (per hour)
+6. IAM Identity Center Store ID
+7. IAM Identity Center Region
You can create a connection using the following curl command:
```bash
@@ -45,6 +48,8 @@ curl 'http://localhost:8080/plugins/q_dev/connections' \
"secretAccessKey": "<YOUR_SECRET_ACCESS_KEY>",
"region": "<AWS_REGION>",
"bucket": "<YOUR_S3_BUCKET_NAME>",
+ "identityStoreId": "<YOUR_IDENTITY_STORE_ID>",
+ "identityStoreRegion": "<YOUR_IDENTITY_CENTER_REGION>",
"rateLimitPerHour": 20000
}'
```
@@ -53,6 +58,8 @@ Please replace the following placeholders with actual values:
<YOUR_SECRET_ACCESS_KEY>: Your AWS secret access key
<YOUR_S3_BUCKET_NAME>: The S3 bucket name you want to use
<AWS_REGION>: The region where your S3 bucket is located
+<YOUR_IDENTITY_STORE_ID>: Your IAM Identity Center Store ID (format:
d-xxxxxxxxxx)
+<YOUR_IDENTITY_CENTER_REGION>: The region where your Identity Center is
deployed
You can get all connections using the following curl command:
```bash
@@ -64,7 +71,7 @@ curl Get 'http://localhost:8080/plugins/q_dev/connections'
The plugin includes the following tasks:
1. `collectQDevS3Files`: Collects file metadata information from S3, without
downloading file content
-2. `extractQDevS3Data`: Uses S3 file metadata to download CSV data and parse
it into the database
+2. `extractQDevS3Data`: Uses S3 file metadata to download CSV data, parse it
into the database, and resolve user display names via Identity Center
3. `convertQDevUserMetrics`: Converts user data into aggregated metrics,
calculating averages and totals
## Data Tables
@@ -74,6 +81,8 @@ The plugin includes the following tasks:
- `_tool_q_dev_user_data`: Stores user data parsed from CSV files
- `_tool_q_dev_user_metrics`: Stores aggregated user metrics
+Note: `_tool_q_dev_user_data` and `_tool_q_dev_user_metrics` tables now
include `display_name` fields for human-readable user identification.
+
## Data Collection Configuration
Steps to collect data:
1. On the Config UI page, select `Advanced Mode` on the left, click
`Blueprints`
diff --git a/backend/plugins/q_dev/api/connection.go
b/backend/plugins/q_dev/api/connection.go
index 24cf0ced4..5a01e8787 100644
--- a/backend/plugins/q_dev/api/connection.go
+++ b/backend/plugins/q_dev/api/connection.go
@@ -28,7 +28,7 @@ import (
// 连接项目的CRUD API
-// PostConnections 创建新连接
+// PostConnections 创建新连接 (enhanced with Identity Store validation)
func PostConnections(input *plugin.ApiResourceInput)
(*plugin.ApiResourceOutput, errors.Error) {
// 创建连接
connection := &models.QDevConnection{}
@@ -36,7 +36,12 @@ func PostConnections(input *plugin.ApiResourceInput)
(*plugin.ApiResourceOutput,
if err != nil {
return nil, err
}
- // 验证
+
+ // 验证连接参数 (enhanced validation)
+ if err := validateConnection(connection); err != nil {
+ return nil, errors.BadInput.Wrap(err, "connection validation
failed")
+ }
+
// 保存到数据库
err = connectionHelper.Create(connection, input)
if err != nil {
@@ -45,7 +50,7 @@ func PostConnections(input *plugin.ApiResourceInput)
(*plugin.ApiResourceOutput,
return &plugin.ApiResourceOutput{Body: connection.Sanitize(), Status:
http.StatusOK}, nil
}
-// PatchConnection 更新现有连接
+// PatchConnection 更新现有连接 (enhanced with Identity Store validation)
func PatchConnection(input *plugin.ApiResourceInput)
(*plugin.ApiResourceOutput, errors.Error) {
connection := &models.QDevConnection{}
if err := connectionHelper.First(&connection, input.Params); err != nil
{
@@ -54,6 +59,12 @@ func PatchConnection(input *plugin.ApiResourceInput)
(*plugin.ApiResourceOutput,
if err := (&models.QDevConnection{}).MergeFromRequest(connection,
input.Body); err != nil {
return nil, errors.Convert(err)
}
+
+ // 验证更新后的连接参数 (enhanced validation)
+ if err := validateConnection(connection); err != nil {
+ return nil, errors.BadInput.Wrap(err, "connection validation
failed")
+ }
+
if err := connectionHelper.SaveWithCreateOrUpdate(connection); err !=
nil {
return nil, err
}
@@ -94,3 +105,38 @@ func GetConnection(input *plugin.ApiResourceInput)
(*plugin.ApiResourceOutput, e
}
return &plugin.ApiResourceOutput{Body: connection.Sanitize()}, err
}
+
+// validateConnection validates connection parameters including Identity Store
fields
+func validateConnection(connection *models.QDevConnection) error {
+ // Validate AWS credentials
+ if connection.AccessKeyId == "" {
+ return errors.Default.New("AccessKeyId is required")
+ }
+ if connection.SecretAccessKey == "" {
+ return errors.Default.New("SecretAccessKey is required")
+ }
+ if connection.Region == "" {
+ return errors.Default.New("Region is required")
+ }
+ if connection.Bucket == "" {
+ return errors.Default.New("Bucket is required")
+ }
+
+ // Validate Identity Store fields (now required)
+ if connection.IdentityStoreId == "" {
+ return errors.Default.New("IdentityStoreId is required")
+ }
+ if connection.IdentityStoreRegion == "" {
+ return errors.Default.New("IdentityStoreRegion is required")
+ }
+
+ // Validate rate limit
+ if connection.RateLimitPerHour < 0 {
+ return errors.Default.New("RateLimitPerHour must be positive")
+ }
+ if connection.RateLimitPerHour == 0 {
+ connection.RateLimitPerHour = 20000 // Set default value
+ }
+
+ return nil
+}
diff --git a/backend/plugins/q_dev/api/connection_test.go
b/backend/plugins/q_dev/api/connection_test.go
new file mode 100644
index 000000000..916fbd155
--- /dev/null
+++ b/backend/plugins/q_dev/api/connection_test.go
@@ -0,0 +1,244 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/apache/incubator-devlake/plugins/q_dev/models"
+)
+
+func TestValidateConnection_Success(t *testing.T) {
+ connection := &models.QDevConnection{
+ QDevConn: models.QDevConn{
+ AccessKeyId: "AKIAIOSFODNN7EXAMPLE",
+ SecretAccessKey:
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ Region: "us-east-1",
+ Bucket: "my-q-dev-bucket",
+ RateLimitPerHour: 20000,
+ IdentityStoreId: "d-1234567890",
+ IdentityStoreRegion: "us-west-2",
+ },
+ }
+
+ err := validateConnection(connection)
+ assert.NoError(t, err)
+}
+
+func TestValidateConnection_MissingAccessKeyId(t *testing.T) {
+ connection := &models.QDevConnection{
+ QDevConn: models.QDevConn{
+ AccessKeyId: "", // Missing
+ SecretAccessKey:
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ Region: "us-east-1",
+ Bucket: "my-q-dev-bucket",
+ IdentityStoreId: "d-1234567890",
+ IdentityStoreRegion: "us-west-2",
+ },
+ }
+
+ err := validateConnection(connection)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "AccessKeyId is required")
+}
+
+func TestValidateConnection_MissingSecretAccessKey(t *testing.T) {
+ connection := &models.QDevConnection{
+ QDevConn: models.QDevConn{
+ AccessKeyId: "AKIAIOSFODNN7EXAMPLE",
+ SecretAccessKey: "", // Missing
+ Region: "us-east-1",
+ Bucket: "my-q-dev-bucket",
+ IdentityStoreId: "d-1234567890",
+ IdentityStoreRegion: "us-west-2",
+ },
+ }
+
+ err := validateConnection(connection)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "SecretAccessKey is required")
+}
+
+func TestValidateConnection_MissingRegion(t *testing.T) {
+ connection := &models.QDevConnection{
+ QDevConn: models.QDevConn{
+ AccessKeyId: "AKIAIOSFODNN7EXAMPLE",
+ SecretAccessKey:
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ Region: "", // Missing
+ Bucket: "my-q-dev-bucket",
+ IdentityStoreId: "d-1234567890",
+ IdentityStoreRegion: "us-west-2",
+ },
+ }
+
+ err := validateConnection(connection)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "Region is required")
+}
+
+func TestValidateConnection_MissingBucket(t *testing.T) {
+ connection := &models.QDevConnection{
+ QDevConn: models.QDevConn{
+ AccessKeyId: "AKIAIOSFODNN7EXAMPLE",
+ SecretAccessKey:
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ Region: "us-east-1",
+ Bucket: "", // Missing
+ IdentityStoreId: "d-1234567890",
+ IdentityStoreRegion: "us-west-2",
+ },
+ }
+
+ err := validateConnection(connection)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "Bucket is required")
+}
+
+func TestValidateConnection_MissingIdentityStoreId(t *testing.T) {
+ connection := &models.QDevConnection{
+ QDevConn: models.QDevConn{
+ AccessKeyId: "AKIAIOSFODNN7EXAMPLE",
+ SecretAccessKey:
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ Region: "us-east-1",
+ Bucket: "my-q-dev-bucket",
+ IdentityStoreId: "", // Missing
+ IdentityStoreRegion: "us-west-2",
+ },
+ }
+
+ err := validateConnection(connection)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "IdentityStoreId is required")
+}
+
+func TestValidateConnection_MissingIdentityStoreRegion(t *testing.T) {
+ connection := &models.QDevConnection{
+ QDevConn: models.QDevConn{
+ AccessKeyId: "AKIAIOSFODNN7EXAMPLE",
+ SecretAccessKey:
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ Region: "us-east-1",
+ Bucket: "my-q-dev-bucket",
+ IdentityStoreId: "d-1234567890",
+ IdentityStoreRegion: "", // Missing
+ },
+ }
+
+ err := validateConnection(connection)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "IdentityStoreRegion is required")
+}
+
+func TestValidateConnection_InvalidRateLimit(t *testing.T) {
+ connection := &models.QDevConnection{
+ QDevConn: models.QDevConn{
+ AccessKeyId: "AKIAIOSFODNN7EXAMPLE",
+ SecretAccessKey:
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ Region: "us-east-1",
+ Bucket: "my-q-dev-bucket",
+ RateLimitPerHour: -1, // Invalid
+ IdentityStoreId: "d-1234567890",
+ IdentityStoreRegion: "us-west-2",
+ },
+ }
+
+ err := validateConnection(connection)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "RateLimitPerHour must be positive")
+}
+
+func TestValidateConnection_DefaultRateLimit(t *testing.T) {
+ connection := &models.QDevConnection{
+ QDevConn: models.QDevConn{
+ AccessKeyId: "AKIAIOSFODNN7EXAMPLE",
+ SecretAccessKey:
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ Region: "us-east-1",
+ Bucket: "my-q-dev-bucket",
+ RateLimitPerHour: 0, // Should get default value
+ IdentityStoreId: "d-1234567890",
+ IdentityStoreRegion: "us-west-2",
+ },
+ }
+
+ err := validateConnection(connection)
+ assert.NoError(t, err)
+ assert.Equal(t, 20000, connection.RateLimitPerHour) // Should be set to
default
+}
+
+func TestConnectionRequestBody_Serialization(t *testing.T) {
+ // Test that the connection can be properly serialized/deserialized
with new fields
+ original := &models.QDevConnection{
+ QDevConn: models.QDevConn{
+ AccessKeyId: "AKIAIOSFODNN7EXAMPLE",
+ SecretAccessKey:
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ Region: "us-east-1",
+ Bucket: "my-q-dev-bucket",
+ RateLimitPerHour: 20000,
+ IdentityStoreId: "d-1234567890",
+ IdentityStoreRegion: "us-west-2",
+ },
+ }
+
+ // Serialize to JSON
+ jsonData, err := json.Marshal(original)
+ assert.NoError(t, err)
+
+ // Deserialize from JSON
+ var deserialized models.QDevConnection
+ err = json.Unmarshal(jsonData, &deserialized)
+ assert.NoError(t, err)
+
+ // Verify all fields are preserved
+ assert.Equal(t, original.AccessKeyId, deserialized.AccessKeyId)
+ assert.Equal(t, original.SecretAccessKey, deserialized.SecretAccessKey)
+ assert.Equal(t, original.Region, deserialized.Region)
+ assert.Equal(t, original.Bucket, deserialized.Bucket)
+ assert.Equal(t, original.RateLimitPerHour,
deserialized.RateLimitPerHour)
+ assert.Equal(t, original.IdentityStoreId, deserialized.IdentityStoreId)
+ assert.Equal(t, original.IdentityStoreRegion,
deserialized.IdentityStoreRegion)
+}
+
+func TestConnectionSanitization_PreservesIdentityStore(t *testing.T) {
+ connection := &models.QDevConnection{
+ QDevConn: models.QDevConn{
+ AccessKeyId: "AKIAIOSFODNN7EXAMPLE",
+ SecretAccessKey:
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ Region: "us-east-1",
+ Bucket: "my-q-dev-bucket",
+ RateLimitPerHour: 20000,
+ IdentityStoreId: "d-1234567890",
+ IdentityStoreRegion: "us-west-2",
+ },
+ }
+
+ sanitized := connection.Sanitize()
+
+ // Secret should be sanitized
+ assert.NotEqual(t, "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
sanitized.SecretAccessKey)
+
+ // Identity Store fields should be preserved
+ assert.Equal(t, "d-1234567890", sanitized.IdentityStoreId)
+ assert.Equal(t, "us-west-2", sanitized.IdentityStoreRegion)
+
+ // Other fields should be preserved
+ assert.Equal(t, "AKIAIOSFODNN7EXAMPLE", sanitized.AccessKeyId)
+ assert.Equal(t, "us-east-1", sanitized.Region)
+ assert.Equal(t, "my-q-dev-bucket", sanitized.Bucket)
+ assert.Equal(t, 20000, sanitized.RateLimitPerHour)
+}
diff --git a/backend/plugins/q_dev/impl/impl.go
b/backend/plugins/q_dev/impl/impl.go
index baa2afa2b..3a93a11ef 100644
--- a/backend/plugins/q_dev/impl/impl.go
+++ b/backend/plugins/q_dev/impl/impl.go
@@ -102,15 +102,23 @@ func (p QDev) PrepareTaskData(taskCtx plugin.TaskContext,
options map[string]int
return nil, err
}
- // 创建S3客户端,替代API客户端
+ // Create S3 client
s3Client, err := tasks.NewQDevS3Client(taskCtx, connection)
if err != nil {
return nil, err
}
+ // Create Identity client (new)
+ identityClient, identityErr := tasks.NewQDevIdentityClient(connection)
+ if identityErr != nil {
+ taskCtx.GetLogger().Warn(identityErr, "Failed to create
identity client, proceeding without user name resolution")
+ identityClient = nil
+ }
+
return &tasks.QDevTaskData{
- Options: &op,
- S3Client: s3Client,
+ Options: &op,
+ S3Client: s3Client,
+ IdentityClient: identityClient,
}, nil
}
diff --git a/backend/plugins/q_dev/impl/impl_test.go
b/backend/plugins/q_dev/impl/impl_test.go
new file mode 100644
index 000000000..568c5af26
--- /dev/null
+++ b/backend/plugins/q_dev/impl/impl_test.go
@@ -0,0 +1,92 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package impl
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/apache/incubator-devlake/plugins/q_dev/tasks"
+)
+
+func TestQDev_BasicPluginMethods(t *testing.T) {
+ plugin := &QDev{}
+
+ assert.Equal(t, "q_dev", plugin.Name())
+ assert.Equal(t, "To collect and enrich data from AWS Q Developer usage
metrics", plugin.Description())
+ assert.Equal(t, "github.com/apache/incubator-devlake/plugins/q_dev",
plugin.RootPkgPath())
+
+ // Test table info
+ tables := plugin.GetTablesInfo()
+ assert.Len(t, tables, 4)
+
+ // Test subtask metas
+ subtasks := plugin.SubTaskMetas()
+ assert.Len(t, subtasks, 3)
+
+ // Test API resources
+ apiResources := plugin.ApiResources()
+ assert.NotEmpty(t, apiResources)
+ assert.Contains(t, apiResources, "test")
+ assert.Contains(t, apiResources, "connections")
+}
+
+func TestQDev_TaskDataStructure(t *testing.T) {
+ // Test that QDevTaskData has the expected structure
+ taskData := &tasks.QDevTaskData{
+ Options: &tasks.QDevOptions{
+ ConnectionId: 1,
+ S3Prefix: "test/",
+ },
+ S3Client: &tasks.QDevS3Client{
+ Bucket: "test-bucket",
+ },
+ IdentityClient: &tasks.QDevIdentityClient{
+ StoreId: "d-1234567890",
+ Region: "us-west-2",
+ },
+ }
+
+ assert.NotNil(t, taskData.Options)
+ assert.NotNil(t, taskData.S3Client)
+ assert.NotNil(t, taskData.IdentityClient)
+
+ assert.Equal(t, uint64(1), taskData.Options.ConnectionId)
+ assert.Equal(t, "test/", taskData.Options.S3Prefix)
+ assert.Equal(t, "test-bucket", taskData.S3Client.Bucket)
+ assert.Equal(t, "d-1234567890", taskData.IdentityClient.StoreId)
+ assert.Equal(t, "us-west-2", taskData.IdentityClient.Region)
+}
+
+func TestQDev_TaskDataWithoutIdentityClient(t *testing.T) {
+ // Test that QDevTaskData works without IdentityClient
+ taskData := &tasks.QDevTaskData{
+ Options: &tasks.QDevOptions{
+ ConnectionId: 1,
+ },
+ S3Client: &tasks.QDevS3Client{
+ Bucket: "test-bucket",
+ },
+ IdentityClient: nil, // No identity client
+ }
+
+ assert.NotNil(t, taskData.Options)
+ assert.NotNil(t, taskData.S3Client)
+ assert.Nil(t, taskData.IdentityClient)
+}
diff --git a/backend/plugins/q_dev/models/connection.go
b/backend/plugins/q_dev/models/connection.go
index 5f56749a5..953e8dad7 100644
--- a/backend/plugins/q_dev/models/connection.go
+++ b/backend/plugins/q_dev/models/connection.go
@@ -28,12 +28,18 @@ type QDevConn struct {
AccessKeyId string `mapstructure:"accessKeyId" json:"accessKeyId"`
// SecretAccessKey for AWS
SecretAccessKey string `mapstructure:"secretAccessKey"
json:"secretAccessKey"`
- // Region for AWS
+ // Region for AWS S3
Region string `mapstructure:"region" json:"region"`
// Bucket for AWS S3
Bucket string `mapstructure:"bucket" json:"bucket"`
// RateLimitPerHour limits the API requests sent to AWS
RateLimitPerHour int `mapstructure:"rateLimitPerHour"
json:"rateLimitPerHour"`
+
+ // Required fields for IAM Identity Center
+ // IdentityStoreId for AWS IAM Identity Center (required for user
display names)
+ IdentityStoreId string `mapstructure:"identityStoreId"
json:"identityStoreId"`
+ // IdentityStoreRegion for AWS IAM Identity Center (required, may
differ from S3 region)
+ IdentityStoreRegion string `mapstructure:"identityStoreRegion"
json:"identityStoreRegion"`
}
func (conn *QDevConn) Sanitize() QDevConn {
diff --git a/backend/plugins/q_dev/models/connection_test.go
b/backend/plugins/q_dev/models/connection_test.go
new file mode 100644
index 000000000..480564813
--- /dev/null
+++ b/backend/plugins/q_dev/models/connection_test.go
@@ -0,0 +1,111 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestQDevConn_WithIdentityStore(t *testing.T) {
+ conn := QDevConn{
+ AccessKeyId: "test-key",
+ SecretAccessKey: "test-secret",
+ Region: "us-east-1",
+ Bucket: "test-bucket",
+ RateLimitPerHour: 20000,
+ IdentityStoreId: "d-1234567890",
+ IdentityStoreRegion: "us-west-2",
+ }
+
+ assert.Equal(t, "d-1234567890", conn.IdentityStoreId)
+ assert.Equal(t, "us-west-2", conn.IdentityStoreRegion)
+ assert.Equal(t, "us-east-1", conn.Region) // S3 region
+}
+
+func TestQDevConn_RequiredFields(t *testing.T) {
+ // Test that all required fields are present
+ conn := QDevConn{
+ AccessKeyId: "test-key",
+ SecretAccessKey: "test-secret",
+ Region: "us-east-1",
+ Bucket: "test-bucket",
+ RateLimitPerHour: 20000,
+ IdentityStoreId: "d-1234567890",
+ IdentityStoreRegion: "us-west-2",
+ }
+
+ // All required fields should be non-empty
+ assert.NotEmpty(t, conn.AccessKeyId)
+ assert.NotEmpty(t, conn.SecretAccessKey)
+ assert.NotEmpty(t, conn.Region)
+ assert.NotEmpty(t, conn.Bucket)
+ assert.NotEmpty(t, conn.IdentityStoreId)
+ assert.NotEmpty(t, conn.IdentityStoreRegion)
+ assert.Greater(t, conn.RateLimitPerHour, 0)
+}
+
+func TestQDevConn_Sanitize_PreservesIdentityStore(t *testing.T) {
+ conn := QDevConn{
+ SecretAccessKey: "secret-key",
+ IdentityStoreId: "d-1234567890",
+ IdentityStoreRegion: "us-west-2",
+ }
+
+ sanitized := conn.Sanitize()
+ assert.NotEqual(t, "secret-key", sanitized.SecretAccessKey)
+ assert.Equal(t, "d-1234567890", sanitized.IdentityStoreId)
+ assert.Equal(t, "us-west-2", sanitized.IdentityStoreRegion)
+}
+
+func TestQDevConnection_WithIdentityStore(t *testing.T) {
+ connection := QDevConnection{
+ QDevConn: QDevConn{
+ AccessKeyId: "test-key",
+ SecretAccessKey: "test-secret",
+ Region: "us-east-1",
+ Bucket: "test-bucket",
+ IdentityStoreId: "d-1234567890",
+ IdentityStoreRegion: "us-west-2",
+ },
+ }
+
+ assert.Equal(t, "d-1234567890", connection.IdentityStoreId)
+ assert.Equal(t, "us-west-2", connection.IdentityStoreRegion)
+}
+
+func TestQDevConnection_Sanitize_WithIdentityStore(t *testing.T) {
+ connection := QDevConnection{
+ QDevConn: QDevConn{
+ SecretAccessKey: "secret-key",
+ IdentityStoreId: "d-1234567890",
+ IdentityStoreRegion: "us-west-2",
+ },
+ }
+
+ sanitized := connection.Sanitize()
+ assert.NotEqual(t, "secret-key", sanitized.SecretAccessKey)
+ assert.Equal(t, "d-1234567890", sanitized.IdentityStoreId)
+ assert.Equal(t, "us-west-2", sanitized.IdentityStoreRegion)
+}
+
+func TestQDevConnection_TableName(t *testing.T) {
+ connection := QDevConnection{}
+ assert.Equal(t, "_tool_q_dev_connections", connection.TableName())
+}
diff --git
a/backend/plugins/q_dev/models/migrationscripts/20250623_add_display_name_fields.go
b/backend/plugins/q_dev/models/migrationscripts/20250623_add_display_name_fields.go
new file mode 100644
index 000000000..e165cfd83
--- /dev/null
+++
b/backend/plugins/q_dev/models/migrationscripts/20250623_add_display_name_fields.go
@@ -0,0 +1,55 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package migrationscripts
+
+import (
+ "github.com/apache/incubator-devlake/core/context"
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+)
+
+var _ plugin.MigrationScript = (*addDisplayNameFields)(nil)
+
+type addDisplayNameFields struct{}
+
+func (*addDisplayNameFields) Up(basicRes context.BasicRes) errors.Error {
+ db := basicRes.GetDal()
+
+ // Add Identity Center fields to connections table
+ // Ignore error if column already exists (MySQL error 1060)
+ _ = db.Exec("ALTER TABLE _tool_q_dev_connections ADD COLUMN
identity_store_id VARCHAR(255)")
+ _ = db.Exec("ALTER TABLE _tool_q_dev_connections ADD COLUMN
identity_store_region VARCHAR(255)")
+
+ // Add display_name column to user_data table
+ // Ignore error if column already exists (MySQL error 1060)
+ _ = db.Exec("ALTER TABLE _tool_q_dev_user_data ADD COLUMN display_name
VARCHAR(255)")
+
+ // Add display_name column to user_metrics table
+ // Ignore error if column already exists (MySQL error 1060)
+ _ = db.Exec("ALTER TABLE _tool_q_dev_user_metrics ADD COLUMN
display_name VARCHAR(255)")
+
+ return nil
+}
+
+func (*addDisplayNameFields) Version() uint64 {
+ return 20250623000001
+}
+
+func (*addDisplayNameFields) Name() string {
+ return "add Identity Center fields to connections and display_name
fields to user tables"
+}
diff --git a/backend/plugins/q_dev/models/migrationscripts/register.go
b/backend/plugins/q_dev/models/migrationscripts/register.go
index b874a19a4..4020753d5 100644
--- a/backend/plugins/q_dev/models/migrationscripts/register.go
+++ b/backend/plugins/q_dev/models/migrationscripts/register.go
@@ -26,5 +26,6 @@ func All() []plugin.MigrationScript {
return []plugin.MigrationScript{
new(initTables),
new(modifyFileMetaTable),
+ new(addDisplayNameFields),
}
}
diff --git a/backend/plugins/q_dev/models/user_data.go
b/backend/plugins/q_dev/models/user_data.go
index ce0692b9b..107076742 100644
--- a/backend/plugins/q_dev/models/user_data.go
+++ b/backend/plugins/q_dev/models/user_data.go
@@ -29,6 +29,7 @@ type QDevUserData struct {
ConnectionId uint64 `gorm:"primaryKey"`
UserId string `gorm:"index" json:"userId"`
Date time.Time `gorm:"index" json:"date"`
+ DisplayName string `gorm:"type:varchar(255)"
json:"displayName"` // New field for user display name
CodeReview_FindingsCount int
CodeReview_SucceededEventCount int
InlineChat_AcceptanceEventCount int
diff --git a/backend/plugins/q_dev/models/user_data_test.go
b/backend/plugins/q_dev/models/user_data_test.go
new file mode 100644
index 000000000..857fb6537
--- /dev/null
+++ b/backend/plugins/q_dev/models/user_data_test.go
@@ -0,0 +1,118 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestQDevUserData_WithDisplayName(t *testing.T) {
+ userData := QDevUserData{
+ ConnectionId: 1,
+ UserId: "uuid-123",
+ DisplayName: "John Doe",
+ Date: time.Now(),
+ CodeReview_FindingsCount: 5,
+ Inline_AcceptanceCount: 10,
+ }
+
+ assert.Equal(t, "John Doe", userData.DisplayName)
+ assert.Equal(t, "uuid-123", userData.UserId)
+ assert.Equal(t, uint64(1), userData.ConnectionId)
+ assert.Equal(t, 5, userData.CodeReview_FindingsCount)
+ assert.Equal(t, 10, userData.Inline_AcceptanceCount)
+}
+
+func TestQDevUserData_WithFallbackDisplayName(t *testing.T) {
+ userData := QDevUserData{
+ ConnectionId: 1,
+ UserId: "uuid-456",
+ DisplayName: "uuid-456", // Fallback case when display name
resolution fails
+ Date: time.Now(),
+ }
+
+ assert.Equal(t, "uuid-456", userData.DisplayName)
+ assert.Equal(t, userData.UserId, userData.DisplayName) // Should match
when fallback
+}
+
+func TestQDevUserData_EmptyDisplayName(t *testing.T) {
+ userData := QDevUserData{
+ ConnectionId: 1,
+ UserId: "uuid-789",
+ DisplayName: "", // Empty display name
+ Date: time.Now(),
+ }
+
+ assert.Equal(t, "", userData.DisplayName)
+ assert.Equal(t, "uuid-789", userData.UserId)
+ assert.NotEqual(t, userData.UserId, userData.DisplayName)
+}
+
+func TestQDevUserData_TableName(t *testing.T) {
+ userData := QDevUserData{}
+ assert.Equal(t, "_tool_q_dev_user_data", userData.TableName())
+}
+
+func TestQDevUserData_AllFields(t *testing.T) {
+ now := time.Now()
+ userData := QDevUserData{
+ ConnectionId: 1,
+ UserId: "test-user",
+ DisplayName: "Test User",
+ Date: now,
+ CodeReview_FindingsCount: 1,
+ CodeReview_SucceededEventCount: 2,
+ InlineChat_AcceptanceEventCount: 3,
+ InlineChat_AcceptedLineAdditions: 4,
+ InlineChat_AcceptedLineDeletions: 5,
+ InlineChat_DismissalEventCount: 6,
+ InlineChat_DismissedLineAdditions: 7,
+ InlineChat_DismissedLineDeletions: 8,
+ InlineChat_RejectedLineAdditions: 9,
+ InlineChat_RejectedLineDeletions: 10,
+ InlineChat_RejectionEventCount: 11,
+ InlineChat_TotalEventCount: 12,
+ Inline_AICodeLines: 13,
+ Inline_AcceptanceCount: 14,
+ Inline_SuggestionsCount: 15,
+ }
+
+ // Verify all fields are properly set
+ assert.Equal(t, uint64(1), userData.ConnectionId)
+ assert.Equal(t, "test-user", userData.UserId)
+ assert.Equal(t, "Test User", userData.DisplayName)
+ assert.Equal(t, now, userData.Date)
+ assert.Equal(t, 1, userData.CodeReview_FindingsCount)
+ assert.Equal(t, 2, userData.CodeReview_SucceededEventCount)
+ assert.Equal(t, 3, userData.InlineChat_AcceptanceEventCount)
+ assert.Equal(t, 4, userData.InlineChat_AcceptedLineAdditions)
+ assert.Equal(t, 5, userData.InlineChat_AcceptedLineDeletions)
+ assert.Equal(t, 6, userData.InlineChat_DismissalEventCount)
+ assert.Equal(t, 7, userData.InlineChat_DismissedLineAdditions)
+ assert.Equal(t, 8, userData.InlineChat_DismissedLineDeletions)
+ assert.Equal(t, 9, userData.InlineChat_RejectedLineAdditions)
+ assert.Equal(t, 10, userData.InlineChat_RejectedLineDeletions)
+ assert.Equal(t, 11, userData.InlineChat_RejectionEventCount)
+ assert.Equal(t, 12, userData.InlineChat_TotalEventCount)
+ assert.Equal(t, 13, userData.Inline_AICodeLines)
+ assert.Equal(t, 14, userData.Inline_AcceptanceCount)
+ assert.Equal(t, 15, userData.Inline_SuggestionsCount)
+}
diff --git a/backend/plugins/q_dev/models/user_metrics.go
b/backend/plugins/q_dev/models/user_metrics.go
index 60ef224f4..71987e92e 100644
--- a/backend/plugins/q_dev/models/user_metrics.go
+++ b/backend/plugins/q_dev/models/user_metrics.go
@@ -28,6 +28,7 @@ type QDevUserMetrics struct {
common.NoPKModel
ConnectionId uint64 `gorm:"primaryKey"`
UserId string `gorm:"primaryKey"`
+ DisplayName string `gorm:"type:varchar(255)" json:"displayName"` //
New field for user display name
FirstDate time.Time
LastDate time.Time
TotalDays int
diff --git a/backend/plugins/q_dev/models/user_metrics_test.go
b/backend/plugins/q_dev/models/user_metrics_test.go
new file mode 100644
index 000000000..0a85d3263
--- /dev/null
+++ b/backend/plugins/q_dev/models/user_metrics_test.go
@@ -0,0 +1,155 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestQDevUserMetrics_WithDisplayName(t *testing.T) {
+ userMetrics := QDevUserMetrics{
+ ConnectionId: 1,
+ UserId: "uuid-123",
+ DisplayName: "John Doe",
+ FirstDate: time.Now().AddDate(0, 0, -30),
+ LastDate: time.Now(),
+ TotalDays: 30,
+ TotalCodeReview_FindingsCount: 50,
+ AcceptanceRate: 0.85,
+ }
+
+ assert.Equal(t, "John Doe", userMetrics.DisplayName)
+ assert.Equal(t, "uuid-123", userMetrics.UserId)
+ assert.Equal(t, uint64(1), userMetrics.ConnectionId)
+ assert.Equal(t, 50, userMetrics.TotalCodeReview_FindingsCount)
+ assert.Equal(t, 0.85, userMetrics.AcceptanceRate)
+}
+
+func TestQDevUserMetrics_WithFallbackDisplayName(t *testing.T) {
+ userMetrics := QDevUserMetrics{
+ ConnectionId: 1,
+ UserId: "uuid-456",
+ DisplayName: "uuid-456", // Fallback case when display name
resolution fails
+ TotalDays: 15,
+ }
+
+ assert.Equal(t, "uuid-456", userMetrics.DisplayName)
+ assert.Equal(t, userMetrics.UserId, userMetrics.DisplayName) // Should
match when fallback
+ assert.Equal(t, 15, userMetrics.TotalDays)
+}
+
+func TestQDevUserMetrics_EmptyDisplayName(t *testing.T) {
+ userMetrics := QDevUserMetrics{
+ ConnectionId: 1,
+ UserId: "uuid-789",
+ DisplayName: "", // Empty display name
+ TotalDays: 5,
+ }
+
+ assert.Equal(t, "", userMetrics.DisplayName)
+ assert.Equal(t, "uuid-789", userMetrics.UserId)
+ assert.NotEqual(t, userMetrics.UserId, userMetrics.DisplayName)
+}
+
+func TestQDevUserMetrics_TableName(t *testing.T) {
+ userMetrics := QDevUserMetrics{}
+ assert.Equal(t, "_tool_q_dev_user_metrics", userMetrics.TableName())
+}
+
+func TestQDevUserMetrics_AllFields(t *testing.T) {
+ firstDate := time.Now().AddDate(0, 0, -30)
+ lastDate := time.Now()
+
+ userMetrics := QDevUserMetrics{
+ ConnectionId: 1,
+ UserId: "test-user",
+ DisplayName: "Test User",
+ FirstDate: firstDate,
+ LastDate: lastDate,
+ TotalDays: 30,
+
+ // 聚合指标
+ TotalCodeReview_FindingsCount: 100,
+ TotalCodeReview_SucceededEventCount: 90,
+ TotalInlineChat_AcceptanceEventCount: 80,
+ TotalInlineChat_AcceptedLineAdditions: 70,
+ TotalInlineChat_AcceptedLineDeletions: 60,
+ TotalInlineChat_DismissalEventCount: 50,
+ TotalInlineChat_DismissedLineAdditions: 40,
+ TotalInlineChat_DismissedLineDeletions: 30,
+ TotalInlineChat_RejectedLineAdditions: 20,
+ TotalInlineChat_RejectedLineDeletions: 10,
+ TotalInlineChat_RejectionEventCount: 5,
+ TotalInlineChat_TotalEventCount: 200,
+ TotalInline_AICodeLines: 1000,
+ TotalInline_AcceptanceCount: 150,
+ TotalInline_SuggestionsCount: 180,
+
+ // 平均指标
+ AvgCodeReview_FindingsCount: 3.33,
+ AvgCodeReview_SucceededEventCount: 3.0,
+ AvgInlineChat_AcceptanceEventCount: 2.67,
+ AvgInlineChat_TotalEventCount: 6.67,
+ AvgInline_AICodeLines: 33.33,
+ AvgInline_AcceptanceCount: 5.0,
+ AvgInline_SuggestionsCount: 6.0,
+
+ // 接受率指标
+ AcceptanceRate: 0.83,
+ }
+
+ // Verify all fields are properly set
+ assert.Equal(t, uint64(1), userMetrics.ConnectionId)
+ assert.Equal(t, "test-user", userMetrics.UserId)
+ assert.Equal(t, "Test User", userMetrics.DisplayName)
+ assert.Equal(t, firstDate, userMetrics.FirstDate)
+ assert.Equal(t, lastDate, userMetrics.LastDate)
+ assert.Equal(t, 30, userMetrics.TotalDays)
+
+ // Test aggregated metrics
+ assert.Equal(t, 100, userMetrics.TotalCodeReview_FindingsCount)
+ assert.Equal(t, 90, userMetrics.TotalCodeReview_SucceededEventCount)
+ assert.Equal(t, 80, userMetrics.TotalInlineChat_AcceptanceEventCount)
+ assert.Equal(t, 70, userMetrics.TotalInlineChat_AcceptedLineAdditions)
+ assert.Equal(t, 60, userMetrics.TotalInlineChat_AcceptedLineDeletions)
+ assert.Equal(t, 50, userMetrics.TotalInlineChat_DismissalEventCount)
+ assert.Equal(t, 40, userMetrics.TotalInlineChat_DismissedLineAdditions)
+ assert.Equal(t, 30, userMetrics.TotalInlineChat_DismissedLineDeletions)
+ assert.Equal(t, 20, userMetrics.TotalInlineChat_RejectedLineAdditions)
+ assert.Equal(t, 10, userMetrics.TotalInlineChat_RejectedLineDeletions)
+ assert.Equal(t, 5, userMetrics.TotalInlineChat_RejectionEventCount)
+ assert.Equal(t, 200, userMetrics.TotalInlineChat_TotalEventCount)
+ assert.Equal(t, 1000, userMetrics.TotalInline_AICodeLines)
+ assert.Equal(t, 150, userMetrics.TotalInline_AcceptanceCount)
+ assert.Equal(t, 180, userMetrics.TotalInline_SuggestionsCount)
+
+ // Test average metrics
+ assert.Equal(t, 3.33, userMetrics.AvgCodeReview_FindingsCount)
+ assert.Equal(t, 3.0, userMetrics.AvgCodeReview_SucceededEventCount)
+ assert.Equal(t, 2.67, userMetrics.AvgInlineChat_AcceptanceEventCount)
+ assert.Equal(t, 6.67, userMetrics.AvgInlineChat_TotalEventCount)
+ assert.Equal(t, 33.33, userMetrics.AvgInline_AICodeLines)
+ assert.Equal(t, 5.0, userMetrics.AvgInline_AcceptanceCount)
+ assert.Equal(t, 6.0, userMetrics.AvgInline_SuggestionsCount)
+
+ // Test acceptance rate
+ assert.Equal(t, 0.83, userMetrics.AcceptanceRate)
+}
diff --git a/backend/plugins/q_dev/tasks/identity_client.go
b/backend/plugins/q_dev/tasks/identity_client.go
new file mode 100644
index 000000000..921d9abfe
--- /dev/null
+++ b/backend/plugins/q_dev/tasks/identity_client.go
@@ -0,0 +1,91 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/aws/credentials"
+ "github.com/aws/aws-sdk-go/aws/session"
+ "github.com/aws/aws-sdk-go/service/identitystore"
+
+ "github.com/apache/incubator-devlake/plugins/q_dev/models"
+)
+
+// IdentityStoreAPI interface for AWS Identity Store operations
+// This allows for easier testing with mocks
+type IdentityStoreAPI interface {
+ DescribeUser(input *identitystore.DescribeUserInput)
(*identitystore.DescribeUserOutput, error)
+}
+
+// QDevIdentityClient wraps AWS Identity Store client for user display name
resolution
+type QDevIdentityClient struct {
+ IdentityStore IdentityStoreAPI
+ StoreId string
+ Region string
+}
+
+// NewQDevIdentityClient creates a new Identity Store client for the given
connection
+// Returns nil if Identity Store is not configured (empty ID or region)
+func NewQDevIdentityClient(connection *models.QDevConnection)
(*QDevIdentityClient, error) {
+ // Return nil if Identity Store is not configured
+ if connection.IdentityStoreId == "" || connection.IdentityStoreRegion
== "" {
+ return nil, nil
+ }
+
+ // Create AWS session with Identity Store region and credentials
+ sess, err := session.NewSession(&aws.Config{
+ Region: aws.String(connection.IdentityStoreRegion),
+ Credentials: credentials.NewStaticCredentials(
+ connection.AccessKeyId,
+ connection.SecretAccessKey,
+ "", // No session token
+ ),
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &QDevIdentityClient{
+ IdentityStore: identitystore.New(sess),
+ StoreId: connection.IdentityStoreId,
+ Region: connection.IdentityStoreRegion,
+ }, nil
+}
+
+// ResolveUserDisplayName resolves a user ID to a human-readable display name
+// Returns the display name if found, otherwise returns the original userId as
fallback
+func (client *QDevIdentityClient) ResolveUserDisplayName(userId string)
(string, error) {
+ input := &identitystore.DescribeUserInput{
+ IdentityStoreId: aws.String(client.StoreId),
+ UserId: aws.String(userId),
+ }
+
+ result, err := client.IdentityStore.DescribeUser(input)
+ if err != nil {
+ // Return userId as fallback on error, but still return the
error for logging
+ return userId, err
+ }
+
+ // Check if DisplayName exists and is not empty
+ if result.DisplayName != nil && *result.DisplayName != "" {
+ return *result.DisplayName, nil
+ }
+
+ // Fallback to userId if no display name available
+ return userId, nil
+}
diff --git a/backend/plugins/q_dev/tasks/identity_client_test.go
b/backend/plugins/q_dev/tasks/identity_client_test.go
new file mode 100644
index 000000000..933457ddc
--- /dev/null
+++ b/backend/plugins/q_dev/tasks/identity_client_test.go
@@ -0,0 +1,195 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/aws/aws-sdk-go/service/identitystore"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+
+ "github.com/apache/incubator-devlake/plugins/q_dev/models"
+)
+
+// Mock IdentityStore interface for testing
+type MockIdentityStoreAPI struct {
+ mock.Mock
+}
+
+func (m *MockIdentityStoreAPI) DescribeUser(input
*identitystore.DescribeUserInput) (*identitystore.DescribeUserOutput, error) {
+ args := m.Called(input)
+ if args.Get(0) == nil {
+ return nil, args.Error(1)
+ }
+ return args.Get(0).(*identitystore.DescribeUserOutput), args.Error(1)
+}
+
+func TestNewQDevIdentityClient_Success(t *testing.T) {
+ connection := &models.QDevConnection{
+ QDevConn: models.QDevConn{
+ AccessKeyId: "test-key",
+ SecretAccessKey: "test-secret",
+ IdentityStoreId: "d-1234567890",
+ IdentityStoreRegion: "us-west-2",
+ },
+ }
+
+ client, err := NewQDevIdentityClient(connection)
+ assert.NoError(t, err)
+ assert.NotNil(t, client)
+ assert.Equal(t, "d-1234567890", client.StoreId)
+ assert.Equal(t, "us-west-2", client.Region)
+}
+
+func TestNewQDevIdentityClient_EmptyIdentityStoreId(t *testing.T) {
+ connection := &models.QDevConnection{
+ QDevConn: models.QDevConn{
+ AccessKeyId: "test-key",
+ SecretAccessKey: "test-secret",
+ IdentityStoreId: "", // Empty identity store ID
+ IdentityStoreRegion: "us-west-2",
+ },
+ }
+
+ client, err := NewQDevIdentityClient(connection)
+ assert.NoError(t, err)
+ assert.Nil(t, client) // Should return nil when no identity store
configured
+}
+
+func TestNewQDevIdentityClient_EmptyIdentityStoreRegion(t *testing.T) {
+ connection := &models.QDevConnection{
+ QDevConn: models.QDevConn{
+ AccessKeyId: "test-key",
+ SecretAccessKey: "test-secret",
+ IdentityStoreId: "d-1234567890",
+ IdentityStoreRegion: "", // Empty identity store region
+ },
+ }
+
+ client, err := NewQDevIdentityClient(connection)
+ assert.NoError(t, err)
+ assert.Nil(t, client) // Should return nil when no region configured
+}
+
+func TestQDevIdentityClient_ResolveUserDisplayName_Success(t *testing.T) {
+ mockAPI := &MockIdentityStoreAPI{}
+ client := &QDevIdentityClient{
+ IdentityStore: mockAPI,
+ StoreId: "d-1234567890",
+ Region: "us-west-2",
+ }
+
+ displayName := "John Doe"
+ mockAPI.On("DescribeUser",
mock.AnythingOfType("*identitystore.DescribeUserInput")).Return(
+ &identitystore.DescribeUserOutput{
+ DisplayName: &displayName,
+ }, nil)
+
+ result, err := client.ResolveUserDisplayName("user-123")
+ assert.NoError(t, err)
+ assert.Equal(t, "John Doe", result)
+
+ mockAPI.AssertExpectations(t)
+}
+
+func TestQDevIdentityClient_ResolveUserDisplayName_NoDisplayName(t *testing.T)
{
+ mockAPI := &MockIdentityStoreAPI{}
+ client := &QDevIdentityClient{
+ IdentityStore: mockAPI,
+ StoreId: "d-1234567890",
+ Region: "us-west-2",
+ }
+
+ // Return output with nil DisplayName
+ mockAPI.On("DescribeUser",
mock.AnythingOfType("*identitystore.DescribeUserInput")).Return(
+ &identitystore.DescribeUserOutput{
+ DisplayName: nil,
+ }, nil)
+
+ result, err := client.ResolveUserDisplayName("user-123")
+ assert.NoError(t, err)
+ assert.Equal(t, "user-123", result) // Should fallback to UUID
+
+ mockAPI.AssertExpectations(t)
+}
+
+func TestQDevIdentityClient_ResolveUserDisplayName_EmptyDisplayName(t
*testing.T) {
+ mockAPI := &MockIdentityStoreAPI{}
+ client := &QDevIdentityClient{
+ IdentityStore: mockAPI,
+ StoreId: "d-1234567890",
+ Region: "us-west-2",
+ }
+
+ emptyName := ""
+ mockAPI.On("DescribeUser",
mock.AnythingOfType("*identitystore.DescribeUserInput")).Return(
+ &identitystore.DescribeUserOutput{
+ DisplayName: &emptyName,
+ }, nil)
+
+ result, err := client.ResolveUserDisplayName("user-123")
+ assert.NoError(t, err)
+ assert.Equal(t, "user-123", result) // Should fallback to UUID when
empty
+
+ mockAPI.AssertExpectations(t)
+}
+
+func TestQDevIdentityClient_ResolveUserDisplayName_APIError(t *testing.T) {
+ mockAPI := &MockIdentityStoreAPI{}
+ client := &QDevIdentityClient{
+ IdentityStore: mockAPI,
+ StoreId: "d-1234567890",
+ Region: "us-west-2",
+ }
+
+ mockAPI.On("DescribeUser",
mock.AnythingOfType("*identitystore.DescribeUserInput")).Return(
+ nil, errors.New("user not found"))
+
+ result, err := client.ResolveUserDisplayName("user-123")
+ assert.Error(t, err)
+ assert.Equal(t, "user-123", result) // Should fallback to UUID on error
+ assert.Contains(t, err.Error(), "user not found")
+
+ mockAPI.AssertExpectations(t)
+}
+
+func TestQDevIdentityClient_ResolveUserDisplayName_InputValidation(t
*testing.T) {
+ mockAPI := &MockIdentityStoreAPI{}
+ client := &QDevIdentityClient{
+ IdentityStore: mockAPI,
+ StoreId: "d-1234567890",
+ Region: "us-west-2",
+ }
+
+ displayName := "Jane Smith"
+ mockAPI.On("DescribeUser", mock.MatchedBy(func(input
*identitystore.DescribeUserInput) bool {
+ // Verify the input parameters are correctly set
+ return *input.IdentityStoreId == "d-1234567890" &&
*input.UserId == "test-user-456"
+ })).Return(
+ &identitystore.DescribeUserOutput{
+ DisplayName: &displayName,
+ }, nil)
+
+ result, err := client.ResolveUserDisplayName("test-user-456")
+ assert.NoError(t, err)
+ assert.Equal(t, "Jane Smith", result)
+
+ mockAPI.AssertExpectations(t)
+}
diff --git a/backend/plugins/q_dev/tasks/s3_data_extractor.go
b/backend/plugins/q_dev/tasks/s3_data_extractor.go
index 748e9b6c4..a4cbbcbdf 100644
--- a/backend/plugins/q_dev/tasks/s3_data_extractor.go
+++ b/backend/plugins/q_dev/tasks/s3_data_extractor.go
@@ -95,6 +95,9 @@ func ExtractQDevS3Data(taskCtx plugin.SubTaskContext)
errors.Error {
func processCSVData(taskCtx plugin.SubTaskContext, db dal.Dal, reader
io.ReadCloser, fileMeta *models.QDevS3FileMeta) errors.Error {
defer reader.Close()
+ // Get task data to access Identity Client
+ data := taskCtx.GetData().(*QDevTaskData)
+
csvReader := csv.NewReader(reader)
// 使用默认的逗号分隔符,不需要设置 Comma
csvReader.LazyQuotes = true // 允许非标准引号处理
@@ -117,8 +120,8 @@ func processCSVData(taskCtx plugin.SubTaskContext, db
dal.Dal, reader io.ReadClo
return errors.Convert(err)
}
- // 创建用户数据对象
- userData, err := createUserData(headers, record, fileMeta)
+ // 创建用户数据对象 (updated to include display name resolution)
+ userData, err := createUserDataWithDisplayName(headers, record,
fileMeta, data.IdentityClient)
if err != nil {
return errors.Default.Wrap(err, "failed to create user
data")
}
@@ -133,8 +136,13 @@ func processCSVData(taskCtx plugin.SubTaskContext, db
dal.Dal, reader io.ReadClo
return nil
}
-// 从CSV记录创建用户数据对象
-func createUserData(headers []string, record []string, fileMeta
*models.QDevS3FileMeta) (*models.QDevUserData, errors.Error) {
+// UserDisplayNameResolver interface for resolving user display names
+type UserDisplayNameResolver interface {
+ ResolveUserDisplayName(userId string) (string, error)
+}
+
+// 从CSV记录创建用户数据对象 (enhanced with display name resolution)
+func createUserDataWithDisplayName(headers []string, record []string, fileMeta
*models.QDevS3FileMeta, identityClient UserDisplayNameResolver)
(*models.QDevUserData, errors.Error) {
userData := &models.QDevUserData{
ConnectionId: fileMeta.ConnectionId,
}
@@ -165,6 +173,9 @@ func createUserData(headers []string, record []string,
fileMeta *models.QDevS3Fi
return nil, errors.Default.New("UserId not found in CSV record")
}
+ // 设置DisplayName (new functionality)
+ userData.DisplayName = resolveDisplayName(userData.UserId,
identityClient)
+
// 设置Date
dateStr, ok := fieldMap["Date"]
if !ok {
@@ -196,6 +207,29 @@ func createUserData(headers []string, record []string,
fileMeta *models.QDevS3Fi
return userData, nil
}
+// resolveDisplayName resolves user ID to display name using Identity Client
+func resolveDisplayName(userId string, identityClient UserDisplayNameResolver)
string {
+ // If no identity client available, use userId as fallback
+ if identityClient == nil {
+ return userId
+ }
+
+ // Try to resolve display name
+ displayName, err := identityClient.ResolveUserDisplayName(userId)
+ if err != nil {
+ // Log error but continue with userId as fallback
+ fmt.Printf("Failed to resolve display name for user %s: %v\n",
userId, err)
+ return userId
+ }
+
+ // If display name is empty, use userId as fallback
+ if displayName == "" {
+ return userId
+ }
+
+ return displayName
+}
+
// 解析日期
func parseDate(dateStr string) (time.Time, errors.Error) {
// 尝试常见的日期格式
diff --git a/backend/plugins/q_dev/tasks/s3_data_extractor_test.go
b/backend/plugins/q_dev/tasks/s3_data_extractor_test.go
new file mode 100644
index 000000000..8c824f3e1
--- /dev/null
+++ b/backend/plugins/q_dev/tasks/s3_data_extractor_test.go
@@ -0,0 +1,201 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+
+ "github.com/apache/incubator-devlake/plugins/q_dev/models"
+)
+
+// Mock Identity Client for testing
+type MockIdentityClient struct {
+ mock.Mock
+}
+
+func (m *MockIdentityClient) ResolveUserDisplayName(userId string) (string,
error) {
+ args := m.Called(userId)
+ return args.String(0), args.Error(1)
+}
+
+// Ensure MockIdentityClient implements UserDisplayNameResolver
+var _ UserDisplayNameResolver = (*MockIdentityClient)(nil)
+
+func TestCreateUserDataWithDisplayName_Success(t *testing.T) {
+ headers := []string{"UserId", "Date", "CodeReview_FindingsCount",
"Inline_AcceptanceCount"}
+ record := []string{"user-123", "2025-06-23", "5", "10"}
+ fileMeta := &models.QDevS3FileMeta{
+ ConnectionId: 1,
+ }
+
+ mockIdentityClient := &MockIdentityClient{}
+ mockIdentityClient.On("ResolveUserDisplayName",
"user-123").Return("John Doe", nil)
+
+ userData, err := createUserDataWithDisplayName(headers, record,
fileMeta, mockIdentityClient)
+
+ assert.NoError(t, err)
+ assert.NotNil(t, userData)
+ assert.Equal(t, "user-123", userData.UserId)
+ assert.Equal(t, "John Doe", userData.DisplayName)
+ assert.Equal(t, uint64(1), userData.ConnectionId)
+ assert.Equal(t, 5, userData.CodeReview_FindingsCount)
+ assert.Equal(t, 10, userData.Inline_AcceptanceCount)
+
+ mockIdentityClient.AssertExpectations(t)
+}
+
+func TestCreateUserDataWithDisplayName_FallbackToUUID(t *testing.T) {
+ headers := []string{"UserId", "Date"}
+ record := []string{"user-456", "2025-06-23"}
+ fileMeta := &models.QDevS3FileMeta{
+ ConnectionId: 1,
+ }
+
+ mockIdentityClient := &MockIdentityClient{}
+ mockIdentityClient.On("ResolveUserDisplayName",
"user-456").Return("user-456", assert.AnError)
+
+ userData, err := createUserDataWithDisplayName(headers, record,
fileMeta, mockIdentityClient)
+
+ assert.NoError(t, err)
+ assert.NotNil(t, userData)
+ assert.Equal(t, "user-456", userData.UserId)
+ assert.Equal(t, "user-456", userData.DisplayName) // Should fallback to
UUID
+
+ mockIdentityClient.AssertExpectations(t)
+}
+
+func TestCreateUserDataWithDisplayName_NoIdentityClient(t *testing.T) {
+ headers := []string{"UserId", "Date"}
+ record := []string{"user-789", "2025-06-23"}
+ fileMeta := &models.QDevS3FileMeta{
+ ConnectionId: 1,
+ }
+
+ userData, err := createUserDataWithDisplayName(headers, record,
fileMeta, nil)
+
+ assert.NoError(t, err)
+ assert.NotNil(t, userData)
+ assert.Equal(t, "user-789", userData.UserId)
+ assert.Equal(t, "user-789", userData.DisplayName) // Should use UUID
when no client
+}
+
+func TestCreateUserDataWithDisplayName_EmptyDisplayName(t *testing.T) {
+ headers := []string{"UserId", "Date"}
+ record := []string{"user-empty", "2025-06-23"}
+ fileMeta := &models.QDevS3FileMeta{
+ ConnectionId: 1,
+ }
+
+ mockIdentityClient := &MockIdentityClient{}
+ mockIdentityClient.On("ResolveUserDisplayName",
"user-empty").Return("", nil)
+
+ userData, err := createUserDataWithDisplayName(headers, record,
fileMeta, mockIdentityClient)
+
+ assert.NoError(t, err)
+ assert.NotNil(t, userData)
+ assert.Equal(t, "user-empty", userData.UserId)
+ assert.Equal(t, "user-empty", userData.DisplayName) // Should fallback
when empty
+
+ mockIdentityClient.AssertExpectations(t)
+}
+
+func TestCreateUserDataWithDisplayName_AllFields(t *testing.T) {
+ headers := []string{
+ "UserId", "Date", "CodeReview_FindingsCount",
"CodeReview_SucceededEventCount",
+ "InlineChat_AcceptanceEventCount",
"InlineChat_AcceptedLineAdditions",
+ "InlineChat_AcceptedLineDeletions",
"InlineChat_DismissalEventCount",
+ "InlineChat_DismissedLineAdditions",
"InlineChat_DismissedLineDeletions",
+ "InlineChat_RejectedLineAdditions",
"InlineChat_RejectedLineDeletions",
+ "InlineChat_RejectionEventCount", "InlineChat_TotalEventCount",
+ "Inline_AICodeLines", "Inline_AcceptanceCount",
"Inline_SuggestionsCount",
+ }
+ record := []string{
+ "test-user", "2025-06-23", "1", "2", "3", "4", "5", "6", "7",
"8", "9", "10", "11", "12", "13", "14", "15",
+ }
+ fileMeta := &models.QDevS3FileMeta{
+ ConnectionId: 123,
+ }
+
+ mockIdentityClient := &MockIdentityClient{}
+ mockIdentityClient.On("ResolveUserDisplayName",
"test-user").Return("Test User", nil)
+
+ userData, err := createUserDataWithDisplayName(headers, record,
fileMeta, mockIdentityClient)
+
+ assert.NoError(t, err)
+ assert.NotNil(t, userData)
+
+ // Verify basic fields
+ assert.Equal(t, "test-user", userData.UserId)
+ assert.Equal(t, "Test User", userData.DisplayName)
+ assert.Equal(t, uint64(123), userData.ConnectionId)
+
+ // Verify date parsing
+ expectedDate, _ := time.Parse("2006-01-02", "2025-06-23")
+ assert.Equal(t, expectedDate, userData.Date)
+
+ // Verify all metric fields
+ assert.Equal(t, 1, userData.CodeReview_FindingsCount)
+ assert.Equal(t, 2, userData.CodeReview_SucceededEventCount)
+ assert.Equal(t, 3, userData.InlineChat_AcceptanceEventCount)
+ assert.Equal(t, 4, userData.InlineChat_AcceptedLineAdditions)
+ assert.Equal(t, 5, userData.InlineChat_AcceptedLineDeletions)
+ assert.Equal(t, 6, userData.InlineChat_DismissalEventCount)
+ assert.Equal(t, 7, userData.InlineChat_DismissedLineAdditions)
+ assert.Equal(t, 8, userData.InlineChat_DismissedLineDeletions)
+ assert.Equal(t, 9, userData.InlineChat_RejectedLineAdditions)
+ assert.Equal(t, 10, userData.InlineChat_RejectedLineDeletions)
+ assert.Equal(t, 11, userData.InlineChat_RejectionEventCount)
+ assert.Equal(t, 12, userData.InlineChat_TotalEventCount)
+ assert.Equal(t, 13, userData.Inline_AICodeLines)
+ assert.Equal(t, 14, userData.Inline_AcceptanceCount)
+ assert.Equal(t, 15, userData.Inline_SuggestionsCount)
+
+ mockIdentityClient.AssertExpectations(t)
+}
+
+func TestCreateUserDataWithDisplayName_MissingUserId(t *testing.T) {
+ headers := []string{"Date", "CodeReview_FindingsCount"}
+ record := []string{"2025-06-23", "5"}
+ fileMeta := &models.QDevS3FileMeta{
+ ConnectionId: 1,
+ }
+
+ userData, err := createUserDataWithDisplayName(headers, record,
fileMeta, nil)
+
+ assert.Error(t, err)
+ assert.Nil(t, userData)
+ assert.Contains(t, err.Error(), "UserId not found")
+}
+
+func TestCreateUserDataWithDisplayName_MissingDate(t *testing.T) {
+ headers := []string{"UserId", "CodeReview_FindingsCount"}
+ record := []string{"user-123", "5"}
+ fileMeta := &models.QDevS3FileMeta{
+ ConnectionId: 1,
+ }
+
+ userData, err := createUserDataWithDisplayName(headers, record,
fileMeta, nil)
+
+ assert.Error(t, err)
+ assert.Nil(t, userData)
+ assert.Contains(t, err.Error(), "Date not found")
+}
diff --git a/backend/plugins/q_dev/tasks/task_data.go
b/backend/plugins/q_dev/tasks/task_data.go
index 240811668..79cf1c902 100644
--- a/backend/plugins/q_dev/tasks/task_data.go
+++ b/backend/plugins/q_dev/tasks/task_data.go
@@ -31,8 +31,9 @@ type QDevOptions struct {
}
type QDevTaskData struct {
- Options *QDevOptions
- S3Client *QDevS3Client
+ Options *QDevOptions
+ S3Client *QDevS3Client
+ IdentityClient *QDevIdentityClient // New field for Identity Center
client
}
type QDevS3Client struct {
diff --git a/backend/plugins/q_dev/tasks/task_data_test.go
b/backend/plugins/q_dev/tasks/task_data_test.go
new file mode 100644
index 000000000..cb8f75437
--- /dev/null
+++ b/backend/plugins/q_dev/tasks/task_data_test.go
@@ -0,0 +1,126 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "testing"
+
+ "github.com/aws/aws-sdk-go/service/s3"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestQDevTaskData_WithIdentityClient(t *testing.T) {
+ taskData := &QDevTaskData{
+ Options: &QDevOptions{
+ ConnectionId: 1,
+ S3Prefix: "test-prefix/",
+ },
+ S3Client: &QDevS3Client{
+ S3: &s3.S3{},
+ Bucket: "test-bucket",
+ },
+ IdentityClient: &QDevIdentityClient{
+ StoreId: "d-1234567890",
+ Region: "us-west-2",
+ },
+ }
+
+ assert.NotNil(t, taskData.IdentityClient)
+ assert.Equal(t, "d-1234567890", taskData.IdentityClient.StoreId)
+ assert.Equal(t, "us-west-2", taskData.IdentityClient.Region)
+ assert.NotNil(t, taskData.S3Client)
+ assert.NotNil(t, taskData.Options)
+}
+
+func TestQDevTaskData_WithoutIdentityClient(t *testing.T) {
+ taskData := &QDevTaskData{
+ Options: &QDevOptions{
+ ConnectionId: 1,
+ S3Prefix: "test-prefix/",
+ },
+ S3Client: &QDevS3Client{
+ S3: &s3.S3{},
+ Bucket: "test-bucket",
+ },
+ IdentityClient: nil, // No identity client configured
+ }
+
+ assert.Nil(t, taskData.IdentityClient)
+ assert.NotNil(t, taskData.S3Client)
+ assert.NotNil(t, taskData.Options)
+ assert.Equal(t, uint64(1), taskData.Options.ConnectionId)
+ assert.Equal(t, "test-prefix/", taskData.Options.S3Prefix)
+}
+
+func TestQDevTaskData_AllFields(t *testing.T) {
+ options := &QDevOptions{
+ ConnectionId: 123,
+ S3Prefix: "data/q-dev/",
+ }
+
+ s3Client := &QDevS3Client{
+ S3: &s3.S3{},
+ Bucket: "my-data-bucket",
+ }
+
+ identityClient := &QDevIdentityClient{
+ StoreId: "d-9876543210",
+ Region: "eu-west-1",
+ }
+
+ taskData := &QDevTaskData{
+ Options: options,
+ S3Client: s3Client,
+ IdentityClient: identityClient,
+ }
+
+ // Verify all fields are properly set
+ assert.Equal(t, options, taskData.Options)
+ assert.Equal(t, s3Client, taskData.S3Client)
+ assert.Equal(t, identityClient, taskData.IdentityClient)
+
+ // Verify nested field access
+ assert.Equal(t, uint64(123), taskData.Options.ConnectionId)
+ assert.Equal(t, "data/q-dev/", taskData.Options.S3Prefix)
+ assert.Equal(t, "my-data-bucket", taskData.S3Client.Bucket)
+ assert.Equal(t, "d-9876543210", taskData.IdentityClient.StoreId)
+ assert.Equal(t, "eu-west-1", taskData.IdentityClient.Region)
+}
+
+func TestQDevTaskData_EmptyStruct(t *testing.T) {
+ taskData := &QDevTaskData{}
+
+ assert.Nil(t, taskData.Options)
+ assert.Nil(t, taskData.S3Client)
+ assert.Nil(t, taskData.IdentityClient)
+}
+
+func TestQDevTaskData_PartialInitialization(t *testing.T) {
+ taskData := &QDevTaskData{
+ Options: &QDevOptions{
+ ConnectionId: 456,
+ },
+ // S3Client and IdentityClient intentionally nil
+ }
+
+ assert.NotNil(t, taskData.Options)
+ assert.Equal(t, uint64(456), taskData.Options.ConnectionId)
+ assert.Equal(t, "", taskData.Options.S3Prefix) // Default empty string
+ assert.Nil(t, taskData.S3Client)
+ assert.Nil(t, taskData.IdentityClient)
+}
diff --git a/backend/plugins/q_dev/tasks/user_metrics_converter.go
b/backend/plugins/q_dev/tasks/user_metrics_converter.go
index 85b102e7c..c639da3e3 100644
--- a/backend/plugins/q_dev/tasks/user_metrics_converter.go
+++ b/backend/plugins/q_dev/tasks/user_metrics_converter.go
@@ -18,6 +18,7 @@ limitations under the License.
package tasks
import (
+ "fmt"
"github.com/apache/incubator-devlake/core/dal"
"github.com/apache/incubator-devlake/core/errors"
"github.com/apache/incubator-devlake/core/plugin"
@@ -28,7 +29,7 @@ import (
var _ plugin.SubTaskEntryPoint = ConvertQDevUserMetrics
-// ConvertQDevUserMetrics 按用户聚合指标
+// ConvertQDevUserMetrics 按用户聚合指标 (enhanced with display name support)
func ConvertQDevUserMetrics(taskCtx plugin.SubTaskContext) errors.Error {
data := taskCtx.GetData().(*QDevTaskData)
db := taskCtx.GetDal()
@@ -42,8 +43,8 @@ func ConvertQDevUserMetrics(taskCtx plugin.SubTaskContext)
errors.Error {
return errors.Default.Wrap(err, "failed to delete previous user
metrics")
}
- // 聚合数据
- userDataMap := make(map[string]*UserMetricsAggregation)
+ // 聚合数据 (updated to include display name)
+ userDataMap := make(map[string]*UserMetricsAggregationWithDisplayName)
cursor, err := db.Cursor(
dal.From(&models.QDevUserData{}),
@@ -67,9 +68,17 @@ func ConvertQDevUserMetrics(taskCtx plugin.SubTaskContext)
errors.Error {
// 获取或创建用户聚合
aggregation, ok := userDataMap[userData.UserId]
if !ok {
- aggregation = &UserMetricsAggregation{
+ // Resolve display name for new user (new functionality)
+ displayName :=
resolveDisplayNameForAggregation(userData.UserId, data.IdentityClient)
+ // If user data already has display name, use it;
otherwise use resolved name
+ if userData.DisplayName != "" {
+ displayName = userData.DisplayName
+ }
+
+ aggregation = &UserMetricsAggregationWithDisplayName{
ConnectionId: userData.ConnectionId,
UserId: userData.UserId,
+ DisplayName: displayName, // New field
FirstDate: userData.Date,
LastDate: userData.Date,
DataCount: 0,
@@ -106,53 +115,8 @@ func ConvertQDevUserMetrics(taskCtx plugin.SubTaskContext)
errors.Error {
// 计算每个用户的平均指标和总天数
for _, aggregation := range userDataMap {
- // 创建指标记录
- metrics := &models.QDevUserMetrics{
- ConnectionId: aggregation.ConnectionId,
- UserId: aggregation.UserId,
- FirstDate: aggregation.FirstDate,
- LastDate: aggregation.LastDate,
- }
-
- // 计算总天数
- metrics.TotalDays =
int(math.Round(aggregation.LastDate.Sub(aggregation.FirstDate).Hours()/24)) + 1
-
- // 设置总计指标
- metrics.TotalCodeReview_FindingsCount =
aggregation.TotalCodeReview_FindingsCount
- metrics.TotalCodeReview_SucceededEventCount =
aggregation.TotalCodeReview_SucceededEventCount
- metrics.TotalInlineChat_AcceptanceEventCount =
aggregation.TotalInlineChat_AcceptanceEventCount
- metrics.TotalInlineChat_AcceptedLineAdditions =
aggregation.TotalInlineChat_AcceptedLineAdditions
- metrics.TotalInlineChat_AcceptedLineDeletions =
aggregation.TotalInlineChat_AcceptedLineDeletions
- metrics.TotalInlineChat_DismissalEventCount =
aggregation.TotalInlineChat_DismissalEventCount
- metrics.TotalInlineChat_DismissedLineAdditions =
aggregation.TotalInlineChat_DismissedLineAdditions
- metrics.TotalInlineChat_DismissedLineDeletions =
aggregation.TotalInlineChat_DismissedLineDeletions
- metrics.TotalInlineChat_RejectedLineAdditions =
aggregation.TotalInlineChat_RejectedLineAdditions
- metrics.TotalInlineChat_RejectedLineDeletions =
aggregation.TotalInlineChat_RejectedLineDeletions
- metrics.TotalInlineChat_RejectionEventCount =
aggregation.TotalInlineChat_RejectionEventCount
- metrics.TotalInlineChat_TotalEventCount =
aggregation.TotalInlineChat_TotalEventCount
- metrics.TotalInline_AICodeLines =
aggregation.TotalInline_AICodeLines
- metrics.TotalInline_AcceptanceCount =
aggregation.TotalInline_AcceptanceCount
- metrics.TotalInline_SuggestionsCount =
aggregation.TotalInline_SuggestionsCount
-
- // 计算平均值指标
- if metrics.TotalDays > 0 {
- metrics.AvgCodeReview_FindingsCount =
float64(aggregation.TotalCodeReview_FindingsCount) / float64(metrics.TotalDays)
- metrics.AvgCodeReview_SucceededEventCount =
float64(aggregation.TotalCodeReview_SucceededEventCount) /
float64(metrics.TotalDays)
- metrics.AvgInlineChat_AcceptanceEventCount =
float64(aggregation.TotalInlineChat_AcceptanceEventCount) /
float64(metrics.TotalDays)
- metrics.AvgInlineChat_TotalEventCount =
float64(aggregation.TotalInlineChat_TotalEventCount) /
float64(metrics.TotalDays)
- metrics.AvgInline_AICodeLines =
float64(aggregation.TotalInline_AICodeLines) / float64(metrics.TotalDays)
- metrics.AvgInline_AcceptanceCount =
float64(aggregation.TotalInline_AcceptanceCount) / float64(metrics.TotalDays)
- metrics.AvgInline_SuggestionsCount =
float64(aggregation.TotalInline_SuggestionsCount) / float64(metrics.TotalDays)
- }
-
- // 计算接受率
- totalEvents := aggregation.TotalInlineChat_AcceptanceEventCount
+
- aggregation.TotalInlineChat_DismissalEventCount +
- aggregation.TotalInlineChat_RejectionEventCount
-
- if totalEvents > 0 {
- metrics.AcceptanceRate =
float64(aggregation.TotalInlineChat_AcceptanceEventCount) / float64(totalEvents)
- }
+ // 创建指标记录 (updated to use new method)
+ metrics := aggregation.ToUserMetrics()
// 存储聚合指标
err = db.Create(metrics)
@@ -166,10 +130,11 @@ func ConvertQDevUserMetrics(taskCtx
plugin.SubTaskContext) errors.Error {
return nil
}
-// UserMetricsAggregation 聚合过程中用于保存用户指标的结构
-type UserMetricsAggregation struct {
+// UserMetricsAggregationWithDisplayName 聚合过程中用于保存用户指标的结构 (enhanced with
display name)
+type UserMetricsAggregationWithDisplayName struct {
ConnectionId uint64
UserId string
+ DisplayName string // New field for display
name
FirstDate time.Time
LastDate time.Time
DataCount int
@@ -190,6 +155,82 @@ type UserMetricsAggregation struct {
TotalInline_SuggestionsCount int
}
+// ToUserMetrics converts aggregation data to QDevUserMetrics model
+func (aggregation *UserMetricsAggregationWithDisplayName) ToUserMetrics()
*models.QDevUserMetrics {
+ metrics := &models.QDevUserMetrics{
+ ConnectionId: aggregation.ConnectionId,
+ UserId: aggregation.UserId,
+ DisplayName: aggregation.DisplayName, // New field
+ FirstDate: aggregation.FirstDate,
+ LastDate: aggregation.LastDate,
+ }
+
+ // 计算总天数
+ metrics.TotalDays =
int(math.Round(aggregation.LastDate.Sub(aggregation.FirstDate).Hours()/24)) + 1
+
+ // 设置总计指标
+ metrics.TotalCodeReview_FindingsCount =
aggregation.TotalCodeReview_FindingsCount
+ metrics.TotalCodeReview_SucceededEventCount =
aggregation.TotalCodeReview_SucceededEventCount
+ metrics.TotalInlineChat_AcceptanceEventCount =
aggregation.TotalInlineChat_AcceptanceEventCount
+ metrics.TotalInlineChat_AcceptedLineAdditions =
aggregation.TotalInlineChat_AcceptedLineAdditions
+ metrics.TotalInlineChat_AcceptedLineDeletions =
aggregation.TotalInlineChat_AcceptedLineDeletions
+ metrics.TotalInlineChat_DismissalEventCount =
aggregation.TotalInlineChat_DismissalEventCount
+ metrics.TotalInlineChat_DismissedLineAdditions =
aggregation.TotalInlineChat_DismissedLineAdditions
+ metrics.TotalInlineChat_DismissedLineDeletions =
aggregation.TotalInlineChat_DismissedLineDeletions
+ metrics.TotalInlineChat_RejectedLineAdditions =
aggregation.TotalInlineChat_RejectedLineAdditions
+ metrics.TotalInlineChat_RejectedLineDeletions =
aggregation.TotalInlineChat_RejectedLineDeletions
+ metrics.TotalInlineChat_RejectionEventCount =
aggregation.TotalInlineChat_RejectionEventCount
+ metrics.TotalInlineChat_TotalEventCount =
aggregation.TotalInlineChat_TotalEventCount
+ metrics.TotalInline_AICodeLines = aggregation.TotalInline_AICodeLines
+ metrics.TotalInline_AcceptanceCount =
aggregation.TotalInline_AcceptanceCount
+ metrics.TotalInline_SuggestionsCount =
aggregation.TotalInline_SuggestionsCount
+
+ // 计算平均值指标
+ if metrics.TotalDays > 0 {
+ metrics.AvgCodeReview_FindingsCount =
float64(aggregation.TotalCodeReview_FindingsCount) / float64(metrics.TotalDays)
+ metrics.AvgCodeReview_SucceededEventCount =
float64(aggregation.TotalCodeReview_SucceededEventCount) /
float64(metrics.TotalDays)
+ metrics.AvgInlineChat_AcceptanceEventCount =
float64(aggregation.TotalInlineChat_AcceptanceEventCount) /
float64(metrics.TotalDays)
+ metrics.AvgInlineChat_TotalEventCount =
float64(aggregation.TotalInlineChat_TotalEventCount) /
float64(metrics.TotalDays)
+ metrics.AvgInline_AICodeLines =
float64(aggregation.TotalInline_AICodeLines) / float64(metrics.TotalDays)
+ metrics.AvgInline_AcceptanceCount =
float64(aggregation.TotalInline_AcceptanceCount) / float64(metrics.TotalDays)
+ metrics.AvgInline_SuggestionsCount =
float64(aggregation.TotalInline_SuggestionsCount) / float64(metrics.TotalDays)
+ }
+
+ // 计算接受率
+ totalEvents := aggregation.TotalInlineChat_AcceptanceEventCount +
+ aggregation.TotalInlineChat_DismissalEventCount +
+ aggregation.TotalInlineChat_RejectionEventCount
+
+ if totalEvents > 0 {
+ metrics.AcceptanceRate =
float64(aggregation.TotalInlineChat_AcceptanceEventCount) / float64(totalEvents)
+ }
+
+ return metrics
+}
+
+// resolveDisplayNameForAggregation resolves display name for user metrics
aggregation
+func resolveDisplayNameForAggregation(userId string, identityClient
UserDisplayNameResolver) string {
+ // If no identity client available, use userId as fallback
+ if identityClient == nil {
+ return userId
+ }
+
+ // Try to resolve display name
+ displayName, err := identityClient.ResolveUserDisplayName(userId)
+ if err != nil {
+ // Log error but continue with userId as fallback
+ fmt.Printf("Failed to resolve display name for user %s during
aggregation: %v\n", userId, err)
+ return userId
+ }
+
+ // If display name is empty, use userId as fallback
+ if displayName == "" {
+ return userId
+ }
+
+ return displayName
+}
+
var ConvertQDevUserMetricsMeta = plugin.SubTaskMeta{
Name: "convertQDevUserMetrics",
EntryPoint: ConvertQDevUserMetrics,
diff --git a/backend/plugins/q_dev/tasks/user_metrics_converter_test.go
b/backend/plugins/q_dev/tasks/user_metrics_converter_test.go
new file mode 100644
index 000000000..15df329f7
--- /dev/null
+++ b/backend/plugins/q_dev/tasks/user_metrics_converter_test.go
@@ -0,0 +1,212 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestUserMetricsAggregationWithDisplayName_SingleUser(t *testing.T) {
+ aggregation := &UserMetricsAggregationWithDisplayName{
+ ConnectionId: 1,
+ UserId: "user-123",
+ DisplayName: "John Doe",
+ FirstDate: time.Date(2025, 6, 20, 0, 0, 0, 0, time.UTC),
+ LastDate: time.Date(2025, 6, 23, 0, 0, 0, 0, time.UTC),
+ DataCount: 4,
+ TotalCodeReview_FindingsCount: 20,
+ TotalInlineChat_AcceptanceEventCount: 40,
+ TotalInline_AcceptanceCount: 60,
+ TotalInline_SuggestionsCount: 80,
+ }
+
+ metrics := aggregation.ToUserMetrics()
+
+ assert.Equal(t, uint64(1), metrics.ConnectionId)
+ assert.Equal(t, "user-123", metrics.UserId)
+ assert.Equal(t, "John Doe", metrics.DisplayName)
+ assert.Equal(t, 4, metrics.TotalDays) // 6/20 to 6/23 = 4 days
+ assert.Equal(t, 20, metrics.TotalCodeReview_FindingsCount)
+ assert.Equal(t, 40, metrics.TotalInlineChat_AcceptanceEventCount)
+ assert.Equal(t, 60, metrics.TotalInline_AcceptanceCount)
+ assert.Equal(t, 80, metrics.TotalInline_SuggestionsCount)
+
+ // Test averages
+ assert.Equal(t, 5.0, metrics.AvgCodeReview_FindingsCount) // 20/4
+ assert.Equal(t, 10.0, metrics.AvgInlineChat_AcceptanceEventCount) //
40/4
+ assert.Equal(t, 15.0, metrics.AvgInline_AcceptanceCount) // 60/4
+ assert.Equal(t, 20.0, metrics.AvgInline_SuggestionsCount) // 80/4
+}
+
+func TestUserMetricsAggregationWithDisplayName_FallbackDisplayName(t
*testing.T) {
+ aggregation := &UserMetricsAggregationWithDisplayName{
+ ConnectionId: 1,
+ UserId: "user-456",
+ DisplayName: "user-456", // Fallback case
+ FirstDate: time.Date(2025, 6, 23, 0, 0, 0, 0, time.UTC),
+ LastDate: time.Date(2025, 6, 23, 0, 0, 0, 0, time.UTC),
+ DataCount: 1,
+ }
+
+ metrics := aggregation.ToUserMetrics()
+
+ assert.Equal(t, "user-456", metrics.UserId)
+ assert.Equal(t, "user-456", metrics.DisplayName)
+ assert.Equal(t, metrics.UserId, metrics.DisplayName) // Should match
when fallback
+ assert.Equal(t, 1, metrics.TotalDays) // Same day = 1 day
+}
+
+func TestUserMetricsAggregationWithDisplayName_AcceptanceRateCalculation(t
*testing.T) {
+ aggregation := &UserMetricsAggregationWithDisplayName{
+ ConnectionId: 1,
+ UserId: "user-789",
+ DisplayName: "Jane Smith",
+ FirstDate: time.Date(2025, 6, 23, 0, 0, 0, 0, time.UTC),
+ LastDate: time.Date(2025, 6, 23, 0, 0, 0, 0, time.UTC),
+ DataCount: 1,
+ TotalInlineChat_AcceptanceEventCount: 80, // Accepted
+ TotalInlineChat_DismissalEventCount: 15, // Dismissed
+ TotalInlineChat_RejectionEventCount: 5, // Rejected
+ // Total events = 80 + 15 + 5 = 100
+ // Acceptance rate = 80/100 = 0.8
+ }
+
+ metrics := aggregation.ToUserMetrics()
+
+ assert.Equal(t, "Jane Smith", metrics.DisplayName)
+ assert.Equal(t, 80, metrics.TotalInlineChat_AcceptanceEventCount)
+ assert.Equal(t, 15, metrics.TotalInlineChat_DismissalEventCount)
+ assert.Equal(t, 5, metrics.TotalInlineChat_RejectionEventCount)
+ assert.Equal(t, 0.8, metrics.AcceptanceRate)
+}
+
+func TestUserMetricsAggregationWithDisplayName_ZeroAcceptanceRate(t
*testing.T) {
+ aggregation := &UserMetricsAggregationWithDisplayName{
+ ConnectionId: 1,
+ UserId: "user-zero",
+ DisplayName: "Zero User",
+ FirstDate: time.Date(2025, 6, 23, 0, 0, 0, 0, time.UTC),
+ LastDate: time.Date(2025, 6, 23, 0, 0, 0, 0, time.UTC),
+ DataCount: 1,
+ // No events = acceptance rate should be 0
+ }
+
+ metrics := aggregation.ToUserMetrics()
+
+ assert.Equal(t, "Zero User", metrics.DisplayName)
+ assert.Equal(t, 0.0, metrics.AcceptanceRate)
+}
+
+func TestUserMetricsAggregationWithDisplayName_AllFields(t *testing.T) {
+ firstDate := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
+ lastDate := time.Date(2025, 6, 30, 0, 0, 0, 0, time.UTC)
+
+ aggregation := &UserMetricsAggregationWithDisplayName{
+ ConnectionId: 123,
+ UserId: "test-user",
+ DisplayName: "Test User",
+ FirstDate: firstDate,
+ LastDate: lastDate,
+ DataCount: 30,
+
+ // Set all total fields
+ TotalCodeReview_FindingsCount: 300,
+ TotalCodeReview_SucceededEventCount: 270,
+ TotalInlineChat_AcceptanceEventCount: 240,
+ TotalInlineChat_AcceptedLineAdditions: 210,
+ TotalInlineChat_AcceptedLineDeletions: 180,
+ TotalInlineChat_DismissalEventCount: 150,
+ TotalInlineChat_DismissedLineAdditions: 120,
+ TotalInlineChat_DismissedLineDeletions: 90,
+ TotalInlineChat_RejectedLineAdditions: 60,
+ TotalInlineChat_RejectedLineDeletions: 30,
+ TotalInlineChat_RejectionEventCount: 15,
+ TotalInlineChat_TotalEventCount: 600,
+ TotalInline_AICodeLines: 3000,
+ TotalInline_AcceptanceCount: 450,
+ TotalInline_SuggestionsCount: 540,
+ }
+
+ metrics := aggregation.ToUserMetrics()
+
+ // Verify basic fields
+ assert.Equal(t, uint64(123), metrics.ConnectionId)
+ assert.Equal(t, "test-user", metrics.UserId)
+ assert.Equal(t, "Test User", metrics.DisplayName)
+ assert.Equal(t, firstDate, metrics.FirstDate)
+ assert.Equal(t, lastDate, metrics.LastDate)
+ assert.Equal(t, 30, metrics.TotalDays) // June 1-30 = 30 days
+
+ // Verify all total fields
+ assert.Equal(t, 300, metrics.TotalCodeReview_FindingsCount)
+ assert.Equal(t, 270, metrics.TotalCodeReview_SucceededEventCount)
+ assert.Equal(t, 240, metrics.TotalInlineChat_AcceptanceEventCount)
+ assert.Equal(t, 210, metrics.TotalInlineChat_AcceptedLineAdditions)
+ assert.Equal(t, 180, metrics.TotalInlineChat_AcceptedLineDeletions)
+ assert.Equal(t, 150, metrics.TotalInlineChat_DismissalEventCount)
+ assert.Equal(t, 120, metrics.TotalInlineChat_DismissedLineAdditions)
+ assert.Equal(t, 90, metrics.TotalInlineChat_DismissedLineDeletions)
+ assert.Equal(t, 60, metrics.TotalInlineChat_RejectedLineAdditions)
+ assert.Equal(t, 30, metrics.TotalInlineChat_RejectedLineDeletions)
+ assert.Equal(t, 15, metrics.TotalInlineChat_RejectionEventCount)
+ assert.Equal(t, 600, metrics.TotalInlineChat_TotalEventCount)
+ assert.Equal(t, 3000, metrics.TotalInline_AICodeLines)
+ assert.Equal(t, 450, metrics.TotalInline_AcceptanceCount)
+ assert.Equal(t, 540, metrics.TotalInline_SuggestionsCount)
+
+ // Verify average fields (all divided by 30 days)
+ assert.Equal(t, 10.0, metrics.AvgCodeReview_FindingsCount) //
300/30
+ assert.Equal(t, 9.0, metrics.AvgCodeReview_SucceededEventCount) //
270/30
+ assert.Equal(t, 8.0, metrics.AvgInlineChat_AcceptanceEventCount) //
240/30
+ assert.Equal(t, 20.0, metrics.AvgInlineChat_TotalEventCount) //
600/30
+ assert.Equal(t, 100.0, metrics.AvgInline_AICodeLines) //
3000/30
+ assert.Equal(t, 15.0, metrics.AvgInline_AcceptanceCount) //
450/30
+ assert.Equal(t, 18.0, metrics.AvgInline_SuggestionsCount) //
540/30
+
+ // Verify acceptance rate: 240 / (240 + 150 + 15) = 240/405 ≈ 0.593
+ expectedAcceptanceRate := 240.0 / (240.0 + 150.0 + 15.0)
+ assert.InDelta(t, expectedAcceptanceRate, metrics.AcceptanceRate, 0.001)
+}
+
+func TestResolveDisplayNameForAggregation_Success(t *testing.T) {
+ mockIdentityClient := &MockIdentityClient{}
+ mockIdentityClient.On("ResolveUserDisplayName",
"user-123").Return("John Doe", nil)
+
+ displayName := resolveDisplayNameForAggregation("user-123",
mockIdentityClient)
+ assert.Equal(t, "John Doe", displayName)
+
+ mockIdentityClient.AssertExpectations(t)
+}
+
+func TestResolveDisplayNameForAggregation_NoClient(t *testing.T) {
+ displayName := resolveDisplayNameForAggregation("user-456", nil)
+ assert.Equal(t, "user-456", displayName)
+}
+
+func TestResolveDisplayNameForAggregation_Error(t *testing.T) {
+ mockIdentityClient := &MockIdentityClient{}
+ mockIdentityClient.On("ResolveUserDisplayName",
"user-error").Return("user-error", assert.AnError)
+
+ displayName := resolveDisplayNameForAggregation("user-error",
mockIdentityClient)
+ assert.Equal(t, "user-error", displayName) // Should fallback to UUID
+
+ mockIdentityClient.AssertExpectations(t)
+}
diff --git a/grafana/dashboards/QDevUserData.json
b/grafana/dashboards/QDevUserData.json
index fd37f1aa9..683dd03e7 100644
--- a/grafana/dashboards/QDevUserData.json
+++ b/grafana/dashboards/QDevUserData.json
@@ -480,7 +480,7 @@
"group": [],
"metricColumn": "none",
"rawQuery": true,
- "rawSql": "SELECT\n user_id,\n SUM(inline_chat_total_event_count)
as 'Total Events',\n SUM(inline_chat_acceptance_event_count) as 'Accepted
Events',\n SUM(inline_chat_dismissal_event_count) as 'Dismissed Events',\n
SUM(inline_chat_rejection_event_count) as 'Rejected Events',\n
SUM(inline_ai_code_lines) as 'AI Code Lines',\n
SUM(inline_chat_acceptance_event_count) /
NULLIF(SUM(inline_chat_total_event_count), 0) as 'Acceptance Rate',\n
MIN(date) as 'First Activity',\n MA [...]
+ "rawSql": "SELECT\n COALESCE(display_name, user_id) as 'User',\n
SUM(inline_chat_total_event_count) as 'Total Events',\n
SUM(inline_chat_acceptance_event_count) as 'Accepted Events',\n
SUM(inline_chat_dismissal_event_count) as 'Dismissed Events',\n
SUM(inline_chat_rejection_event_count) as 'Rejected Events',\n
SUM(inline_ai_code_lines) as 'AI Code Lines',\n
SUM(inline_chat_acceptance_event_count) /
NULLIF(SUM(inline_chat_total_event_count), 0) as 'Acceptance Rate',\n MI [...]
"refId": "A",
"select": [
[
diff --git a/grafana/dashboards/QDevUserMetrics.json
b/grafana/dashboards/QDevUserMetrics.json
index eb5a2ce2b..df945a46b 100644
--- a/grafana/dashboards/QDevUserMetrics.json
+++ b/grafana/dashboards/QDevUserMetrics.json
@@ -158,7 +158,7 @@
"group": [],
"metricColumn": "none",
"rawQuery": true,
- "rawSql": "SELECT\n user_id,\n total_inline_chat_total_event_count
as 'Total Events'\nFROM lake._tool_q_dev_user_metrics\nORDER BY
total_inline_chat_total_event_count DESC\nLIMIT 10",
+ "rawSql": "SELECT\n COALESCE(display_name, user_id) as 'User',\n
total_inline_chat_total_event_count as 'Total Events'\nFROM
lake._tool_q_dev_user_metrics\nORDER BY total_inline_chat_total_event_count
DESC\nLIMIT 10",
"refId": "A",
"select": [
[
@@ -249,7 +249,7 @@
"group": [],
"metricColumn": "none",
"rawQuery": true,
- "rawSql": "SELECT\n user_id,\n acceptance_rate\nFROM
lake._tool_q_dev_user_metrics\nWHERE total_inline_chat_total_event_count >
10\nORDER BY acceptance_rate DESC\nLIMIT 10",
+ "rawSql": "SELECT\n COALESCE(display_name, user_id) as 'User',\n
acceptance_rate\nFROM lake._tool_q_dev_user_metrics\nWHERE
total_inline_chat_total_event_count > 10\nORDER BY acceptance_rate DESC\nLIMIT
10",
"refId": "A",
"select": [
[
@@ -352,7 +352,7 @@
"group": [],
"metricColumn": "none",
"rawQuery": true,
- "rawSql": "SELECT\n user_id,\n first_date,\n last_date,\n
total_days,\n total_inline_chat_total_event_count as 'Total Events',\n
total_inline_chat_acceptance_event_count as 'Accepted Events',\n
total_inline_chat_dismissal_event_count as 'Dismissed Events',\n
total_inline_chat_rejection_event_count as 'Rejected Events',\n
total_inline_ai_code_lines as 'AI Code Lines',\n acceptance_rate\nFROM
lake._tool_q_dev_user_metrics\nORDER BY total_inline_chat_total_event_count
DESC",
+ "rawSql": "SELECT\n COALESCE(display_name, user_id) as 'User',\n
first_date,\n last_date,\n total_days,\n total_inline_chat_total_event_count
as 'Total Events',\n total_inline_chat_acceptance_event_count as 'Accepted
Events',\n total_inline_chat_dismissal_event_count as 'Dismissed Events',\n
total_inline_chat_rejection_event_count as 'Rejected Events',\n
total_inline_ai_code_lines as 'AI Code Lines',\n acceptance_rate\nFROM
lake._tool_q_dev_user_metrics\nORDER BY tota [...]
"refId": "A",
"select": [
[
@@ -675,7 +675,7 @@
"group": [],
"metricColumn": "none",
"rawQuery": true,
- "rawSql": "SELECT\n user_id,\n total_code_review_findings_count as
'Total Findings',\n total_code_review_succeeded_event_count as 'Succeeded
Events',\n avg_code_review_findings_count as 'Avg Findings per Day',\n
avg_code_review_succeeded_event_count as 'Avg Succeeded Events per Day'\nFROM
lake._tool_q_dev_user_metrics\nWHERE total_code_review_findings_count >
0\nORDER BY total_code_review_findings_count DESC",
+ "rawSql": "SELECT\n COALESCE(display_name, user_id) as 'User',\n
total_code_review_findings_count as 'Total Findings',\n
total_code_review_succeeded_event_count as 'Succeeded Events',\n
avg_code_review_findings_count as 'Avg Findings per Day',\n
avg_code_review_succeeded_event_count as 'Avg Succeeded Events per Day'\nFROM
lake._tool_q_dev_user_metrics\nWHERE total_code_review_findings_count >
0\nORDER BY total_code_review_findings_count DESC",
"refId": "A",
"select": [
[