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

lynwee 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 2b8dc3225 feat(customize): add account handling for imported issues 
(#8401)
2b8dc3225 is described below

commit 2b8dc32254d61cdaa59991754de852bedaef9197
Author: NaRro <cong.w...@merico.dev>
AuthorDate: Thu Apr 24 13:03:08 2025 +0800

    feat(customize): add account handling for imported issues (#8401)
    
    * feat(customize): add account handling for imported issues
    
    - Create or update accounts for creator and assignee
    - Link accounts to issues in the database
    - Add account data to issues_output.csv
    - Verify account data with snapshot_tables/accounts.csv
    
    #8400
    
    * refactor(customize): extract getStringField function for better record 
handling
    
    - Add getStringField function to extract and validate string fields from 
records
    - Use getStringField to replace repetitive code in issueHandlerFactory
    - Add unit tests for getStringField to ensure its correctness
    
    #8400
---
 .../plugins/customize/e2e/import_issues_test.go    |  11 ++
 .../customize/e2e/raw_tables/issues_input.csv      |   6 +-
 .../customize/e2e/snapshot_tables/accounts.csv     |   5 +
 .../e2e/snapshot_tables/issues_output.csv          |  12 +--
 backend/plugins/customize/service/service.go       | 119 ++++++++++++++++++---
 backend/plugins/customize/service/service_test.go  | 115 ++++++++++++++++++++
 6 files changed, 246 insertions(+), 22 deletions(-)

diff --git a/backend/plugins/customize/e2e/import_issues_test.go 
b/backend/plugins/customize/e2e/import_issues_test.go
index aa875eb92..82de28d09 100644
--- a/backend/plugins/customize/e2e/import_issues_test.go
+++ b/backend/plugins/customize/e2e/import_issues_test.go
@@ -21,6 +21,7 @@ import (
        "os"
        "testing"
 
+       
"github.com/apache/incubator-devlake/core/models/domainlayer/crossdomain"
        "github.com/apache/incubator-devlake/core/models/domainlayer/ticket"
        "github.com/apache/incubator-devlake/helpers/e2ehelper"
        "github.com/apache/incubator-devlake/plugins/customize/impl"
@@ -37,6 +38,7 @@ func TestImportIssueDataFlow(t *testing.T) {
        dataflowTester.FlushTabler(&models.CustomizedField{})
        dataflowTester.FlushTabler(&ticket.IssueLabel{})
        dataflowTester.FlushTabler(&ticket.BoardIssue{})
+       dataflowTester.FlushTabler(&crossdomain.Account{})
        svc := service.NewService(dataflowTester.Dal)
        err := svc.CreateField(&models.CustomizedField{
                TbName:      "issues",
@@ -172,4 +174,13 @@ func TestImportIssueDataFlow(t *testing.T) {
                        "board_id",
                        "issue_id",
                })
+       dataflowTester.VerifyTableWithRawData(
+               &crossdomain.Account{},
+               "snapshot_tables/accounts.csv",
+               []string{
+                       "id",
+                       "full_name",
+                       "user_name",
+               },
+       )
 }
diff --git a/backend/plugins/customize/e2e/raw_tables/issues_input.csv 
b/backend/plugins/customize/e2e/raw_tables/issues_input.csv
index 32e72055f..1eacd5fcd 100644
--- a/backend/plugins/customize/e2e/raw_tables/issues_input.csv
+++ b/backend/plugins/customize/e2e/raw_tables/issues_input.csv
@@ -1,4 +1,4 @@
 
id,url,issue_key,title,original_type,original_status,created_date,resolution_date,story_point,priority,severity,original_estimate_minutes,time_spent_minutes,component,epic_key,creator_name,assignee_name,x_int,x_time,x_varchar,x_float,labels
-csv:1,https://api.bitbucket.org/2.0/repositories/thenicetgp/lake/issues/1,1,issue
 test,BUG,new,2022-07-17 
07:15:55.959+00:00,NULL,0,major,,0,0,,,tgp,tgp,10,2022-09-15 
15:27:56,world,8,NULL
-csv:10,https://api.bitbucket.org/2.0/repositories/thenicetgp/lake/issues/10,10,issue
 test007,BUG,new,2022-08-12 
13:43:00.783+00:00,NULL,0,trivial,,0,0,,,tgp,tgp,30,2022-09-15 
15:27:56,abc,24590,hello worlds
-csv:11,https://api.bitbucket.org/2.0/repositories/thenicetgp/lake/issues/11,11,issue
 test011,REQUIREMENT,new,2022-08-10 
13:44:46.508+00:00,NULL,0,major,,0,0,,,tgp,,1,2022-09-15 
15:27:56,NULL,0.00014,NULL
+csv:1,https://api.bitbucket.org/2.0/repositories/thenicetgp/lake/issues/1,1,issue
 test,BUG,new,2022-07-17 
07:15:55.959+00:00,NULL,0,major,,0,0,,,tgp,klesh,10,2022-09-15 
15:27:56,world,8,NULL
+csv:10,https://api.bitbucket.org/2.0/repositories/thenicetgp/lake/issues/10,10,issue
 test007,BUG,new,2022-08-12 
13:43:00.783+00:00,NULL,0,trivial,,0,0,,,tgp,warren,30,2022-09-15 
15:27:56,abc,24590,hello worlds
+csv:11,https://api.bitbucket.org/2.0/repositories/thenicetgp/lake/issues/11,11,issue
 test011,REQUIREMENT,new,2022-08-10 
13:44:46.508+00:00,NULL,0,major,,0,0,,,tgp,abeizn,1,2022-09-15 
15:27:56,NULL,0.00014,NULL
diff --git a/backend/plugins/customize/e2e/snapshot_tables/accounts.csv 
b/backend/plugins/customize/e2e/snapshot_tables/accounts.csv
new file mode 100644
index 000000000..5d48240be
--- /dev/null
+++ b/backend/plugins/customize/e2e/snapshot_tables/accounts.csv
@@ -0,0 +1,5 @@
+id,full_name,user_name,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark
+csv:CsvAccount:0:abeizn,abeizn,abeizn,csv-board,,0,
+csv:CsvAccount:0:klesh,klesh,klesh,csv-board,,0,
+csv:CsvAccount:0:tgp,tgp,tgp,csv-board2,,0,
+csv:CsvAccount:0:warren,warren,warren,csv-board,,0,
diff --git a/backend/plugins/customize/e2e/snapshot_tables/issues_output.csv 
b/backend/plugins/customize/e2e/snapshot_tables/issues_output.csv
index 43d522071..ffcffa88f 100644
--- a/backend/plugins/customize/e2e/snapshot_tables/issues_output.csv
+++ b/backend/plugins/customize/e2e/snapshot_tables/issues_output.csv
@@ -1,7 +1,7 @@
 
id,url,icon_url,issue_key,title,description,epic_key,type,original_type,status,original_status,story_point,resolution_date,created_date,updated_date,lead_time_minutes,parent_issue_id,priority,original_estimate_minutes,time_spent_minutes,time_remaining_minutes,creator_id,creator_name,assignee_id,assignee_name,severity,component,original_project,x_varchar,x_text,x_time,x_float,x_int,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark
-csv:1,https://api.bitbucket.org/2.0/repositories/thenicetgp/lake/issues/1,,1,issue
 
test,,,,BUG,,new,0,,2022-07-17T07:15:55.959+00:00,,,,major,0,0,,,tgp,,tgp,,,,world,,2022-09-15T15:27:56.000+00:00,8,10,csv-board2,,,
-csv:10,https://api.bitbucket.org/2.0/repositories/thenicetgp/lake/issues/10,,10,issue
 title 
edited,,,,BUG,,new,0,,2022-08-12T13:43:00.783+00:00,,,,trivial,0,0,,,tgp,,tgp,,,,abc,,2022-09-15T15:27:56.000+00:00,24590,30,csv-board2,,,
-csv:11,https://api.bitbucket.org/2.0/repositories/thenicetgp/lake/issues/11,,11,issue
 
test011,,,,REQUIREMENT,,new,0,,2022-08-10T13:44:46.508+00:00,,,,major,0,0,,,tgp,,,,,,,,2022-09-15T15:27:56.000+00:00,0.00014,1,csv-board,,,
-csv:12,https://api.bitbucket.org/2.0/repositories/thenicetgp/lake/issues/12,,12,issue
 
test012,,,,REQUIREMENT,,new,0,,2022-08-11T13:44:46.508+00:00,,,,major,0,0,,,tgp,,,,,,,,2022-09-15T15:27:56.000+00:00,0.00014,1,csv-board,,,
-csv:13,https://api.bitbucket.org/2.0/repositories/thenicetgp/lake/issues/13,,13,issue
 
test013,,,,REQUIREMENT,,new,0,,2022-08-12T13:44:46.508+00:00,,,,critical,0,0,,,tgp,,,,,,,,2022-09-15T15:27:56.000+00:00,0.00014,1,csv-board2,,,
-csv:14,https://api.bitbucket.org/2.0/repositories/thenicetgp/lake/issues/14,,14,issue
 
test014,,,,INCIDENT,,new,0,,2022-08-12T13:45:12.810+00:00,,,,blocker,0,0,,,tgp,,tgp,,,,,,2022-09-15T15:27:56.000+00:00,,41534568464351,csv-board2,,,
+csv:1,https://api.bitbucket.org/2.0/repositories/thenicetgp/lake/issues/1,,1,issue
 
test,,,,BUG,,new,0,,2022-07-17T07:15:55.959+00:00,,,,major,0,0,,csv:CsvAccount:0:tgp,tgp,csv:CsvAccount:0:tgp,tgp,,,,world,,2022-09-15T15:27:56.000+00:00,8,10,csv-board2,,,
+csv:10,https://api.bitbucket.org/2.0/repositories/thenicetgp/lake/issues/10,,10,issue
 title 
edited,,,,BUG,,new,0,,2022-08-12T13:43:00.783+00:00,,,,trivial,0,0,,csv:CsvAccount:0:tgp,tgp,csv:CsvAccount:0:tgp,tgp,,,,abc,,2022-09-15T15:27:56.000+00:00,24590,30,csv-board2,,,
+csv:11,https://api.bitbucket.org/2.0/repositories/thenicetgp/lake/issues/11,,11,issue
 
test011,,,,REQUIREMENT,,new,0,,2022-08-10T13:44:46.508+00:00,,,,major,0,0,,csv:CsvAccount:0:tgp,tgp,csv:CsvAccount:0:abeizn,abeizn,,,,,,2022-09-15T15:27:56.000+00:00,0.00014,1,csv-board,,,
+csv:12,https://api.bitbucket.org/2.0/repositories/thenicetgp/lake/issues/12,,12,issue
 
test012,,,,REQUIREMENT,,new,0,,2022-08-11T13:44:46.508+00:00,,,,major,0,0,,csv:CsvAccount:0:tgp,tgp,,,,,,,,2022-09-15T15:27:56.000+00:00,0.00014,1,csv-board,,,
+csv:13,https://api.bitbucket.org/2.0/repositories/thenicetgp/lake/issues/13,,13,issue
 
test013,,,,REQUIREMENT,,new,0,,2022-08-12T13:44:46.508+00:00,,,,critical,0,0,,csv:CsvAccount:0:tgp,tgp,,,,,,,,2022-09-15T15:27:56.000+00:00,0.00014,1,csv-board2,,,
+csv:14,https://api.bitbucket.org/2.0/repositories/thenicetgp/lake/issues/14,,14,issue
 
test014,,,,INCIDENT,,new,0,,2022-08-12T13:45:12.810+00:00,,,,blocker,0,0,,csv:CsvAccount:0:tgp,tgp,csv:CsvAccount:0:tgp,tgp,,,,,,2022-09-15T15:27:56.000+00:00,,41534568464351,csv-board2,,,
diff --git a/backend/plugins/customize/service/service.go 
b/backend/plugins/customize/service/service.go
index 6586dd13a..bd8b0aa9d 100644
--- a/backend/plugins/customize/service/service.go
+++ b/backend/plugins/customize/service/service.go
@@ -156,6 +156,7 @@ func (s *Service) getCustomizedFields(table string) 
([]models.CustomizedField, e
 // issue could exist in multiple boards, so we should only delete an old 
records when it doesn't belong to another board
 func (s *Service) ImportIssue(boardId string, file io.ReadCloser, incremental 
bool) errors.Error {
        if !incremental {
+               // not delete accounts data since account may be referenced by 
others
                err := s.dal.Delete(
                        &ticket.Issue{},
                        dal.Where("id IN (SELECT issue_id FROM board_issues 
WHERE board_id=? AND issue_id NOT IN (SELECT issue_id FROM board_issues WHERE 
board_id!=?))", boardId, boardId),
@@ -262,23 +263,74 @@ func (s *Service) importCSV(file io.ReadCloser, 
rawDataParams string, recordHand
        }
 }
 
+// createOrUpdateAccount creates or updates an account based on the provided 
name.
+// It returns the account ID and an error if any occurred.
+func (s *Service) createOrUpdateAccount(accountName string, rawDataParams 
string) (string, errors.Error) {
+       if accountName == "" {
+               return "", nil // Return empty ID if name is empty, no error 
needed here.
+       }
+       now := time.Now()
+       accountId := fmt.Sprintf("csv:CsvAccount:0:%s", accountName)
+       account := &crossdomain.Account{
+               DomainEntity: domainlayer.DomainEntity{
+                       Id: accountId,
+                       NoPKModel: common.NoPKModel{
+                               RawDataOrigin: common.RawDataOrigin{
+                                       RawDataParams: rawDataParams,
+                               },
+                       },
+               },
+               FullName:    accountName,
+               UserName:    accountName,
+               CreatedDate: &now,
+       }
+       err := s.dal.CreateOrUpdate(account)
+       if err != nil {
+               return "", errors.Default.Wrap(err, fmt.Sprintf("failed to 
create or update account for %s", accountName))
+       }
+       return accountId, nil
+}
+
+// getStringField extracts a string field from a record map.
+// If required is true, it returns an error if the field is missing, nil, 
empty, or not a string.
+// If required is false, it returns an empty string without error if the field 
is missing or nil,
+// but returns an error if the field exists and is not a string.
+func getStringField(record map[string]interface{}, fieldName string, required 
bool) (string, errors.Error) {
+       value, ok := record[fieldName]
+       if !ok || value == nil {
+               if required {
+                       return "", errors.Default.New(fmt.Sprintf("record 
without required field %s", fieldName))
+               }
+               return "", nil // Field missing or nil, but not required
+       }
+
+       strValue, ok := value.(string)
+       if !ok {
+               return "", errors.Default.New(fmt.Sprintf("%s is not a string", 
fieldName))
+       }
+
+       if required && strValue == "" {
+               return "", errors.Default.New(fmt.Sprintf("invalid or empty 
required field %s", fieldName))
+       }
+
+       return strValue, nil
+}
+
 // issueHandlerFactory returns a handler that save record into `issues`, 
`board_issues` and `issue_labels` table
 func (s *Service) issueHandlerFactory(boardId string, incremental bool) 
func(record map[string]interface{}) errors.Error {
        return func(record map[string]interface{}) errors.Error {
                var err errors.Error
-               var id string
-               if record["id"] == nil {
-                       return errors.Default.New("record without id")
+               id, err := getStringField(record, "id", true)
+               if err != nil {
+                       return err
                }
-               id, _ = record["id"].(string)
-               if id == "" {
-                       return errors.Default.New("empty id")
+
+               // Handle labels
+               labels, err := getStringField(record, "labels", false)
+               if err != nil {
+                       return err
                }
-               if record["labels"] != nil {
-                       labels, ok := record["labels"].(string)
-                       if !ok {
-                               return errors.Default.New("labels is not 
string")
-                       }
+               if labels != "" {
                        var issueLabels []*ticket.IssueLabel
                        appearedLabels := make(map[string]struct{}) // record 
the labels that have appeared
                        for _, label := range strings.Split(labels, ",") {
@@ -307,15 +359,56 @@ func (s *Service) issueHandlerFactory(boardId string, 
incremental bool) func(rec
                                }
                        }
                }
-               delete(record, "labels")
+               delete(record, "labels") // Remove labels from record map as 
it's handled
+
+               // Handle creator and assignee accounts
+               rawDataParams, err := getStringField(record, 
"_raw_data_params", true)
+               if err != nil {
+                       // This should ideally not happen as it's set in 
importCSV, but good to check
+                       return err
+               }
+
+               // Handle creator
+               creatorName, err := getStringField(record, "creator_name", 
false)
+               if err != nil {
+                       return err
+               }
+               creatorId, err := s.createOrUpdateAccount(creatorName, 
rawDataParams)
+               if err != nil {
+                       return err
+               }
+               if creatorId != "" {
+                       record["creator_id"] = creatorId
+               }
+
+               // Handle assignee
+               assigneeName, err := getStringField(record, "assignee_name", 
false)
+               if err != nil {
+                       return err
+               }
+               assigneeId, err := s.createOrUpdateAccount(assigneeName, 
rawDataParams)
+               if err != nil {
+                       return err
+               }
+               if assigneeId != "" {
+                       record["assignee_id"] = assigneeId
+               }
+
+               // Handle issues
                err = s.dal.CreateWithMap(&ticket.Issue{}, record)
                if err != nil {
                        return err
                }
-               return s.dal.CreateOrUpdate(&ticket.BoardIssue{
+
+               // Handle board_issues
+               err = s.dal.CreateOrUpdate(&ticket.BoardIssue{
                        BoardId: boardId,
                        IssueId: id,
                })
+               if err != nil {
+                       return err
+               }
+               return nil
        }
 }
 
diff --git a/backend/plugins/customize/service/service_test.go 
b/backend/plugins/customize/service/service_test.go
index 8242e5f87..5511145f2 100644
--- a/backend/plugins/customize/service/service_test.go
+++ b/backend/plugins/customize/service/service_test.go
@@ -21,6 +21,8 @@ import (
        "regexp"
        "strings"
        "testing"
+
+       "github.com/stretchr/testify/assert"
 )
 
 func TestService_checkFieldName(t *testing.T) {
@@ -75,3 +77,116 @@ func TestService_checkFieldName(t *testing.T) {
                })
        }
 }
+
+func TestGetStringField(t *testing.T) {
+       testCases := []struct {
+               name        string
+               record      map[string]interface{}
+               fieldName   string
+               required    bool
+               expectValue string
+               expectError bool
+               errorMsg    string
+       }{
+               // Required field tests
+               {
+                       name:        "Required field exists and valid",
+                       record:      map[string]interface{}{"id": "123"},
+                       fieldName:   "id",
+                       required:    true,
+                       expectValue: "123",
+                       expectError: false,
+               },
+               {
+                       name:        "Required field exists but empty",
+                       record:      map[string]interface{}{"id": ""},
+                       fieldName:   "id",
+                       required:    true,
+                       expectValue: "",
+                       expectError: true,
+                       errorMsg:    "invalid or empty required field id",
+               },
+               {
+                       name:        "Required field exists but wrong type",
+                       record:      map[string]interface{}{"id": 123},
+                       fieldName:   "id",
+                       required:    true,
+                       expectValue: "",
+                       expectError: true,
+                       errorMsg:    "id is not a string",
+               },
+               {
+                       name:        "Required field missing",
+                       record:      map[string]interface{}{"name": "test"},
+                       fieldName:   "id",
+                       required:    true,
+                       expectValue: "",
+                       expectError: true,
+                       errorMsg:    "record without required field id",
+               },
+               {
+                       name:        "Required field nil",
+                       record:      map[string]interface{}{"id": nil},
+                       fieldName:   "id",
+                       required:    true,
+                       expectValue: "",
+                       expectError: true,
+                       errorMsg:    "record without required field id",
+               },
+               // Optional field tests
+               {
+                       name:        "Optional field exists and valid",
+                       record:      map[string]interface{}{"label": "bug"},
+                       fieldName:   "label",
+                       required:    false,
+                       expectValue: "bug",
+                       expectError: false,
+               },
+               {
+                       name:        "Optional field exists but empty",
+                       record:      map[string]interface{}{"label": ""},
+                       fieldName:   "label",
+                       required:    false,
+                       expectValue: "",
+                       expectError: false,
+               },
+               {
+                       name:        "Optional field exists but wrong type",
+                       record:      map[string]interface{}{"label": 123},
+                       fieldName:   "label",
+                       required:    false,
+                       expectValue: "",
+                       expectError: true,
+                       errorMsg:    "label is not a string",
+               },
+               {
+                       name:        "Optional field missing",
+                       record:      map[string]interface{}{"name": "test"},
+                       fieldName:   "label",
+                       required:    false,
+                       expectValue: "",
+                       expectError: false,
+               },
+               {
+                       name:        "Optional field nil",
+                       record:      map[string]interface{}{"label": nil},
+                       fieldName:   "label",
+                       required:    false,
+                       expectValue: "",
+                       expectError: false,
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       value, err := getStringField(tc.record, tc.fieldName, 
tc.required)
+                       assert.Equal(t, tc.expectValue, value)
+                       if tc.expectError {
+                               assert.Error(t, err)
+                               assert.Contains(t, err.Error(), tc.errorMsg)
+                       } else {
+                               assert.NoError(t, err)
+                       }
+               })
+       }
+}

Reply via email to