This is an automated email from the ASF dual-hosted git repository.

klesh 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 920bc624f feature: add q dev blueprint (#8650)
920bc624f is described below

commit 920bc624f1c04bc01e944410d413fa113ed25554
Author: Warren Chen <[email protected]>
AuthorDate: Tue Nov 25 16:02:36 2025 +0800

    feature: add q dev blueprint (#8650)
    
    * feat(plugins): add q dev blueprint
    
    * fix: lint
---
 backend/plugins/q_dev/api/blueprint_v200.go        |  95 +++++++++++
 backend/plugins/q_dev/impl/impl.go                 |   9 +
 ...=> 20251123_add_scope_config_id_to_s3_slice.go} |  33 +++-
 .../q_dev/models/migrationscripts/register.go      |   1 +
 backend/plugins/q_dev/tasks/identity_client.go     |   5 +
 backend/plugins/q_dev/tasks/s3_data_extractor.go   |   1 +
 backend/plugins/q_dev/tasks/s3_file_collector.go   |   1 +
 .../q-dev/connection-fields/connection-test.tsx    | 185 +++++++++++++++++++++
 8 files changed, 321 insertions(+), 9 deletions(-)

diff --git a/backend/plugins/q_dev/api/blueprint_v200.go 
b/backend/plugins/q_dev/api/blueprint_v200.go
new file mode 100644
index 000000000..a7a015982
--- /dev/null
+++ b/backend/plugins/q_dev/api/blueprint_v200.go
@@ -0,0 +1,95 @@
+/*
+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 (
+       "github.com/apache/incubator-devlake/core/errors"
+       coreModels "github.com/apache/incubator-devlake/core/models"
+       "github.com/apache/incubator-devlake/core/plugin"
+       helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+       "github.com/apache/incubator-devlake/helpers/srvhelper"
+       "github.com/apache/incubator-devlake/plugins/q_dev/models"
+       "github.com/apache/incubator-devlake/plugins/q_dev/tasks"
+)
+
+func MakeDataSourcePipelinePlanV200(
+       subtaskMetas []plugin.SubTaskMeta,
+       connectionId uint64,
+       bpScopes []*coreModels.BlueprintScope,
+) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) {
+       // load connection and scope from the db
+       connection, err := dsHelper.ConnSrv.FindByPk(connectionId)
+       if err != nil {
+               return nil, nil, err
+       }
+       scopeDetails, err := dsHelper.ScopeSrv.MapScopeDetails(connectionId, 
bpScopes)
+       if err != nil {
+               return nil, nil, err
+       }
+
+       plan, err := makeDataSourcePipelinePlanV200(subtaskMetas, scopeDetails, 
connection)
+       if err != nil {
+               return nil, nil, err
+       }
+       scopes, err := makeScopesV200(scopeDetails, connection)
+       if err != nil {
+               return nil, nil, err
+       }
+
+       return plan, scopes, nil
+}
+
+func makeDataSourcePipelinePlanV200(
+       subtaskMetas []plugin.SubTaskMeta,
+       scopeDetails []*srvhelper.ScopeDetail[models.QDevS3Slice, 
srvhelper.NoScopeConfig],
+       connection *models.QDevConnection,
+) (coreModels.PipelinePlan, errors.Error) {
+       plan := make(coreModels.PipelinePlan, len(scopeDetails))
+       for i, scopeDetail := range scopeDetails {
+               s3Slice := scopeDetail.Scope
+               stage := plan[i]
+               if stage == nil {
+                       stage = coreModels.PipelineStage{}
+               }
+
+               // construct task options for q_dev
+               op := &tasks.QDevOptions{
+                       ConnectionId: s3Slice.ConnectionId,
+                       S3Prefix:     s3Slice.Prefix,
+               }
+
+               // Pass empty entities array to enable all subtasks
+               task, err := helper.MakePipelinePlanTask("q_dev", subtaskMetas, 
[]string{}, op)
+               if err != nil {
+                       return nil, err
+               }
+               stage = append(stage, task)
+               plan[i] = stage
+       }
+       return plan, nil
+}
+
+func makeScopesV200(
+       scopeDetails []*srvhelper.ScopeDetail[models.QDevS3Slice, 
srvhelper.NoScopeConfig],
+       connection *models.QDevConnection,
+) ([]plugin.Scope, errors.Error) {
+       scopes := make([]plugin.Scope, 0)
+       // For Q Developer metrics, we don't need to create domain layer scopes
+       // The data is collected and stored directly in the tool layer
+       return scopes, nil
+}
diff --git a/backend/plugins/q_dev/impl/impl.go 
b/backend/plugins/q_dev/impl/impl.go
index 9c3824924..80118212e 100644
--- a/backend/plugins/q_dev/impl/impl.go
+++ b/backend/plugins/q_dev/impl/impl.go
@@ -23,6 +23,7 @@ import (
        "github.com/apache/incubator-devlake/core/context"
        "github.com/apache/incubator-devlake/core/dal"
        "github.com/apache/incubator-devlake/core/errors"
+       coreModels "github.com/apache/incubator-devlake/core/models"
        "github.com/apache/incubator-devlake/core/plugin"
        helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
        "github.com/apache/incubator-devlake/plugins/q_dev/api"
@@ -39,6 +40,7 @@ var _ interface {
        plugin.PluginModel
        plugin.PluginSource
        plugin.PluginMigration
+       plugin.DataSourcePluginBlueprintV200
        plugin.CloseablePluginTask
 } = (*QDev)(nil)
 
@@ -170,3 +172,10 @@ func (p QDev) Close(taskCtx plugin.TaskContext) 
errors.Error {
        data.S3Client.Close()
        return nil
 }
+
+func (p QDev) MakeDataSourcePipelinePlanV200(
+       connectionId uint64,
+       scopes []*coreModels.BlueprintScope,
+) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) {
+       return api.MakeDataSourcePipelinePlanV200(p.SubTaskMetas(), 
connectionId, scopes)
+}
diff --git a/backend/plugins/q_dev/models/migrationscripts/register.go 
b/backend/plugins/q_dev/models/migrationscripts/20251123_add_scope_config_id_to_s3_slice.go
similarity index 54%
copy from backend/plugins/q_dev/models/migrationscripts/register.go
copy to 
backend/plugins/q_dev/models/migrationscripts/20251123_add_scope_config_id_to_s3_slice.go
index dfff79fae..e072e4f59 100644
--- a/backend/plugins/q_dev/models/migrationscripts/register.go
+++ 
b/backend/plugins/q_dev/models/migrationscripts/20251123_add_scope_config_id_to_s3_slice.go
@@ -18,16 +18,31 @@ limitations under the License.
 package migrationscripts
 
 import (
-       "github.com/apache/incubator-devlake/core/plugin"
+       "github.com/apache/incubator-devlake/core/context"
+       "github.com/apache/incubator-devlake/core/errors"
 )
 
-// All return all migration scripts
-func All() []plugin.MigrationScript {
-       return []plugin.MigrationScript{
-               new(initTables),
-               new(modifyFileMetaTable),
-               new(addDisplayNameFields),
-               new(addMissingMetrics),
-               new(addS3SliceTable),
+type addScopeConfigIdToS3Slice struct{}
+
+func (*addScopeConfigIdToS3Slice) Up(basicRes context.BasicRes) errors.Error {
+       db := basicRes.GetDal()
+
+       // Add scope_config_id column to _tool_q_dev_s3_slices table
+       err := db.Exec(`
+               ALTER TABLE _tool_q_dev_s3_slices
+               ADD COLUMN scope_config_id BIGINT UNSIGNED DEFAULT 0
+       `)
+       if err != nil {
+               return errors.Convert(err)
        }
+
+       return nil
+}
+
+func (*addScopeConfigIdToS3Slice) Version() uint64 {
+       return 20251123000001
+}
+
+func (*addScopeConfigIdToS3Slice) Name() string {
+       return "Add scope_config_id column to S3 slice table"
 }
diff --git a/backend/plugins/q_dev/models/migrationscripts/register.go 
b/backend/plugins/q_dev/models/migrationscripts/register.go
index dfff79fae..427b3ac61 100644
--- a/backend/plugins/q_dev/models/migrationscripts/register.go
+++ b/backend/plugins/q_dev/models/migrationscripts/register.go
@@ -29,5 +29,6 @@ func All() []plugin.MigrationScript {
                new(addDisplayNameFields),
                new(addMissingMetrics),
                new(addS3SliceTable),
+               new(addScopeConfigIdToS3Slice),
        }
 }
diff --git a/backend/plugins/q_dev/tasks/identity_client.go 
b/backend/plugins/q_dev/tasks/identity_client.go
index 921d9abfe..855ce4ebd 100644
--- a/backend/plugins/q_dev/tasks/identity_client.go
+++ b/backend/plugins/q_dev/tasks/identity_client.go
@@ -70,6 +70,11 @@ func NewQDevIdentityClient(connection 
*models.QDevConnection) (*QDevIdentityClie
 // 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) {
+       // Check if client or IdentityStore is nil
+       if client == nil || client.IdentityStore == nil {
+               return userId, nil
+       }
+
        input := &identitystore.DescribeUserInput{
                IdentityStoreId: aws.String(client.StoreId),
                UserId:          aws.String(userId),
diff --git a/backend/plugins/q_dev/tasks/s3_data_extractor.go 
b/backend/plugins/q_dev/tasks/s3_data_extractor.go
index 10ab6a7cd..8612ae03b 100644
--- a/backend/plugins/q_dev/tasks/s3_data_extractor.go
+++ b/backend/plugins/q_dev/tasks/s3_data_extractor.go
@@ -318,4 +318,5 @@ var ExtractQDevS3DataMeta = plugin.SubTaskMeta{
        EnabledByDefault: true,
        Description:      "Extract data from S3 CSV files and save to database",
        DomainTypes:      []string{plugin.DOMAIN_TYPE_CROSS},
+       Dependencies:     []*plugin.SubTaskMeta{&CollectQDevS3FilesMeta},
 }
diff --git a/backend/plugins/q_dev/tasks/s3_file_collector.go 
b/backend/plugins/q_dev/tasks/s3_file_collector.go
index 37abf1845..e889d60db 100644
--- a/backend/plugins/q_dev/tasks/s3_file_collector.go
+++ b/backend/plugins/q_dev/tasks/s3_file_collector.go
@@ -114,4 +114,5 @@ var CollectQDevS3FilesMeta = plugin.SubTaskMeta{
        EntryPoint:       CollectQDevS3Files,
        EnabledByDefault: true,
        Description:      "Collect S3 file metadata from AWS S3 bucket",
+       DomainTypes:      []string{plugin.DOMAIN_TYPE_CROSS},
 }
diff --git 
a/config-ui/src/plugins/register/q-dev/connection-fields/connection-test.tsx 
b/config-ui/src/plugins/register/q-dev/connection-fields/connection-test.tsx
new file mode 100644
index 000000000..bc0eb7339
--- /dev/null
+++ b/config-ui/src/plugins/register/q-dev/connection-fields/connection-test.tsx
@@ -0,0 +1,185 @@
+/*
+ * 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.
+ *
+ */
+
+import { useState } from 'react';
+import { Button, Alert, Space } from 'antd';
+import { CheckCircleOutlined, ExclamationCircleOutlined, LoadingOutlined } 
from '@ant-design/icons';
+
+import API from '@/api';
+import { operator } from '@/utils';
+
+interface Props {
+  plugin: string;
+  connectionId?: ID;
+  values: any;
+  initialValues: any;
+  disabled?: boolean;
+}
+
+interface TestResult {
+  success: boolean;
+  message: string;
+  details?: {
+    s3Access?: boolean;
+    identityCenterAccess?: boolean;
+  };
+}
+
+export const QDevConnectionTest = ({ plugin, connectionId, values, 
initialValues, disabled }: Props) => {
+  const [testing, setTesting] = useState(false);
+  const [testResult, setTestResult] = useState<TestResult | null>(null);
+
+  const handleTest = async () => {
+    setTesting(true);
+    setTestResult(null);
+
+    try {
+      const [success, result] = await operator(
+        () => {
+          if (connectionId) {
+            // Test existing connection with only changed values
+            return API.connection.test(plugin, connectionId, {
+              authType: values.authType !== initialValues.authType ? 
values.authType : undefined,
+              accessKeyId: values.accessKeyId !== initialValues.accessKeyId ? 
values.accessKeyId : undefined,
+              secretAccessKey: values.secretAccessKey !== 
initialValues.secretAccessKey ? values.secretAccessKey : undefined,
+              region: values.region !== initialValues.region ? values.region : 
undefined,
+              bucket: values.bucket !== initialValues.bucket ? values.bucket : 
undefined,
+              identityStoreId: values.identityStoreId !== 
initialValues.identityStoreId ? values.identityStoreId : undefined,
+              identityStoreRegion: values.identityStoreRegion !== 
initialValues.identityStoreRegion ? values.identityStoreRegion : undefined,
+              rateLimitPerHour: values.rateLimitPerHour !== 
initialValues.rateLimitPerHour ? values.rateLimitPerHour : undefined,
+              proxy: values.proxy !== initialValues.proxy ? values.proxy : 
undefined,
+            } as any);
+          } else {
+            // Test new connection with all values
+            return API.connection.testOld(plugin, {
+              authType: values.authType || 'access_key',
+              accessKeyId: values.accessKeyId || '',
+              secretAccessKey: values.secretAccessKey || '',
+              region: values.region || '',
+              bucket: values.bucket || '',
+              identityStoreId: values.identityStoreId || '',
+              identityStoreRegion: values.identityStoreRegion || '',
+              rateLimitPerHour: values.rateLimitPerHour || 20000,
+              proxy: values.proxy || '',
+              endpoint: '', // Not used by Q Developer
+              token: '', // Not used by Q Developer
+            } as any);
+          }
+        },
+        {
+          setOperating: () => {}, // We handle loading state ourselves
+          hideToast: true, // We show our own success/error messages
+        },
+      );
+
+      if (success && result) {
+        setTestResult({
+          success: true,
+          message: 'Connection test successful! AWS credentials and S3 access 
verified.',
+          details: {
+            s3Access: true,
+            identityCenterAccess: values.identityStoreId ? true : undefined,
+          },
+        });
+      } else {
+        setTestResult({
+          success: false,
+          message: 'Connection test failed. Please check your configuration.',
+        });
+      }
+    } catch (error: any) {
+      let errorMessage = 'Connection test failed. Please check your 
configuration.';
+      
+      if (error?.response?.data?.message) {
+        errorMessage = error.response.data.message;
+      } else if (error?.message) {
+        errorMessage = error.message;
+      }
+
+      // Provide more specific error messages based on common issues
+      if (errorMessage.includes('InvalidAccessKeyId') || 
errorMessage.includes('SignatureDoesNotMatch')) {
+        errorMessage = 'Invalid AWS credentials. Please check your Access Key 
ID and Secret Access Key.';
+      } else if (errorMessage.includes('NoSuchBucket')) {
+        errorMessage = 'S3 bucket not found. Please check the bucket name and 
region.';
+      } else if (errorMessage.includes('AccessDenied')) {
+        errorMessage = 'Access denied. Please check your AWS permissions for 
S3 and IAM Identity Center.';
+      } else if (errorMessage.includes('InvalidBucketName')) {
+        errorMessage = 'Invalid S3 bucket name. Please check the bucket name 
format.';
+      } else if (errorMessage.includes('NoCredentialsError')) {
+        errorMessage = 'AWS credentials not found. Please provide valid Access 
Key ID and Secret Access Key, or ensure IAM role is properly configured.';
+      }
+
+      setTestResult({
+        success: false,
+        message: errorMessage,
+      });
+    } finally {
+      setTesting(false);
+    }
+  };
+
+  const getAlertType = () => {
+    if (!testResult) return undefined;
+    return testResult.success ? 'success' : 'error';
+  };
+
+  const getAlertIcon = () => {
+    if (testing) return <LoadingOutlined />;
+    if (!testResult) return undefined;
+    return testResult.success ? <CheckCircleOutlined /> : 
<ExclamationCircleOutlined />;
+  };
+
+  return (
+    <Space direction="vertical" style={{ width: '100%' }}>
+      <Button
+        type="default"
+        loading={testing}
+        disabled={disabled || testing}
+        onClick={handleTest}
+        style={{ marginTop: 16 }}
+      >
+        {testing ? 'Testing Connection...' : 'Test Connection'}
+      </Button>
+
+      {(testResult || testing) && (
+        <Alert
+          type={getAlertType()}
+          icon={getAlertIcon()}
+          message={testing ? 'Testing connection to AWS S3 and IAM Identity 
Center...' : testResult?.message}
+          description={
+            testResult?.success && testResult.details ? (
+              <div>
+                <div>✓ S3 Access: Verified</div>
+                {testResult.details.identityCenterAccess && (
+                  <div>✓ IAM Identity Center: Configured</div>
+                )}
+                {!values.identityStoreId && (
+                  <div style={{ marginTop: 8, color: '#faad14' }}>
+                    ⚠️ IAM Identity Center not configured - user display names 
will show as user IDs
+                  </div>
+                )}
+              </div>
+            ) : undefined
+          }
+          showIcon
+          style={{ marginTop: 8 }}
+        />
+      )}
+    </Space>
+  );
+};
\ No newline at end of file

Reply via email to