This is an automated email from the ASF dual-hosted git repository.
narro pushed a commit to branch feat/due_date
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git
The following commit(s) were added to refs/heads/feat/due_date by this push:
new d4e798a9f feat: support customize due date field in jira plugin
d4e798a9f is described below
commit d4e798a9f8cb450f06e7d82246751aa28afcbd2c
Author: narro wizard <[email protected]>
AuthorDate: Tue Mar 4 14:31:30 2025 +0800
feat: support customize due date field in jira plugin
#8315
---
backend/core/models/common/iso8601time.go | 10 ++
backend/core/models/common/iso8601time_test.go | 82 ++++++++++++
backend/plugins/jira/e2e/issue_test.go | 139 +++++++++++++++++++++
.../_raw_jira_api_issues_for_due_date.csv | 2 +
.../_tool_jira_issues_for_due_date.csv | 2 +
.../e2e/snapshot_tables/issues_for_due_date.csv | 2 +
backend/plugins/jira/models/scope_config.go | 1 +
backend/plugins/jira/tasks/apiv2models/issue.go | 12 --
backend/plugins/jira/tasks/issue_extractor.go | 30 ++++-
9 files changed, 267 insertions(+), 13 deletions(-)
diff --git a/backend/core/models/common/iso8601time.go
b/backend/core/models/common/iso8601time.go
index 512a22070..63f9084a2 100644
--- a/backend/core/models/common/iso8601time.go
+++ b/backend/core/models/common/iso8601time.go
@@ -138,6 +138,16 @@ func ConvertStringToTime(timeString string) (t time.Time,
err error) {
return time.Parse(time.RFC3339, timeString)
}
+// ConvertStringToTimeInTz
+func ConvertStringToTimeInLoc(timeString string, loc *time.Location) (t
time.Time, err error) {
+ for _, formatItem := range DateTimeFormats {
+ if formatItem.Matcher.MatchString(timeString) {
+ return time.ParseInLocation(formatItem.Format,
timeString, loc)
+ }
+ }
+ return time.ParseInLocation(time.RFC3339, timeString, loc)
+}
+
// Iso8601TimeToTime FIXME ...
func Iso8601TimeToTime(iso8601Time *Iso8601Time) *time.Time {
if iso8601Time == nil {
diff --git a/backend/core/models/common/iso8601time_test.go
b/backend/core/models/common/iso8601time_test.go
index 380c4b9f1..5a6448a76 100644
--- a/backend/core/models/common/iso8601time_test.go
+++ b/backend/core/models/common/iso8601time_test.go
@@ -124,3 +124,85 @@ func TestIso8601Time_Scan(t *testing.T) {
})
}
}
+
+func TestConvertStringToTime(t *testing.T) {
+ testCases := []struct {
+ name string
+ input string
+ output time.Time
+ err error
+ }{
+ {
+ name: "Valid time string",
+ input: "2023-03-01T12:30:00+0000",
+ output: time.Date(2023, 3, 1, 12, 30, 0, 0,
time.UTC).Local(),
+ err: nil,
+ },
+ {
+ name: "Valid date string",
+ input: "2023-03-01",
+ output: time.Date(2023, 3, 1, 0, 0, 0, 0, time.UTC),
+ err: nil,
+ },
+ {
+ name: "Invalid time string",
+ input: "invalid",
+ output: time.Time{},
+ err: fmt.Errorf("parsing time \"invalid\" as
\"2006-01-02T15:04:05Z07:00\": cannot parse \"invalid\" as \"2006\""),
+ },
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ output, err := ConvertStringToTime(tc.input)
+ if !reflect.DeepEqual(tc.output, output) {
+ t.Errorf("Expected output to be %v, but got
%v", tc.output, output)
+ }
+ assert.Equal(t, fmt.Sprintf("%v", err),
fmt.Sprintf("%v", tc.err), "Expected error to be %v, but got %v", tc.err, err)
+ })
+ }
+}
+
+func TestConvertStringToTimeInLoc(t *testing.T) {
+ loc, err := time.LoadLocation("Asia/Shanghai")
+ if err != nil {
+ t.Fatalf("Failed to load location: %v", err)
+ }
+ testCases := []struct {
+ name string
+ input string
+ loc *time.Location
+ output time.Time
+ err error
+ }{
+ {
+ name: "Valid time string with location",
+ input: "2023-03-01T12:30:00+0800",
+ loc: loc,
+ output: time.Date(2023, 3, 1, 12, 30, 0, 0, loc),
+ err: nil,
+ },
+ {
+ name: "Valid date string with location",
+ input: "2023-03-01",
+ loc: loc,
+ output: time.Date(2023, 3, 1, 0, 0, 0, 0, loc),
+ err: nil,
+ },
+ {
+ name: "Invalid time string with location",
+ input: "invalid",
+ loc: loc,
+ output: time.Time{},
+ err: fmt.Errorf("parsing time \"invalid\" as
\"2006-01-02T15:04:05Z07:00\": cannot parse \"invalid\" as \"2006\""),
+ },
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ output, err := ConvertStringToTimeInLoc(tc.input,
tc.loc)
+ if !reflect.DeepEqual(tc.output, output) {
+ t.Errorf("Expected output to be %v, but got
%v", tc.output, output)
+ }
+ assert.Equal(t, fmt.Sprintf("%v", err),
fmt.Sprintf("%v", tc.err), "Expected error to be %v, but got %v", tc.err, err)
+ })
+ }
+}
diff --git a/backend/plugins/jira/e2e/issue_test.go
b/backend/plugins/jira/e2e/issue_test.go
index 8037c54de..5d50b6e04 100644
--- a/backend/plugins/jira/e2e/issue_test.go
+++ b/backend/plugins/jira/e2e/issue_test.go
@@ -250,3 +250,142 @@ func TestIssueDataFlow(t *testing.T) {
IgnoreTypes: []interface{}{common.NoPKModel{}},
})
}
+
+func TestIssueCustomizeDueDate(t *testing.T) {
+ var plugin impl.Jira
+ dataflowTester := e2ehelper.NewDataFlowTester(t, "jira", plugin)
+
+ taskData := &tasks.JiraTaskData{
+ Options: &tasks.JiraOptions{
+ ConnectionId: 2,
+ BoardId: 8,
+ ScopeConfig: &models.JiraScopeConfig{
+ StoryPointField: "customfield_10024",
+ DueDateField: "customfield_10003",
+ TypeMappings: map[string]models.TypeMapping{
+ "子任务": {
+ StandardType: "Sub-task",
+ StatusMappings:
map[string]models.StatusMapping{
+ "done":
{StandardStatus: "你好世界"},
+ "new":
{StandardStatus: "\u6069\u5E95\u6EF4\u68AF\u6B38\u592B\u5178\u4EA2\u59C6"},
+ },
+ },
+ "任务": {
+ StandardType: "Task",
+ StatusMappings:
map[string]models.StatusMapping{
+ "done":
{StandardStatus: "hello world"},
+ "new":
{StandardStatus: "110 100 100 116 102 46 99 111 109"},
+ },
+ },
+ // issueType "Test Execution" in
raw_data and not fill here to test issueType not be defined
+ },
+ },
+ },
+ }
+ // import raw data table
+
dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_jira_api_issues_for_due_date.csv",
"_raw_jira_api_issues")
+
dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_jira_api_issue_types.csv",
"_raw_jira_api_issue_types")
+
+ // verify issue extraction
+ dataflowTester.FlushTabler(&models.JiraIssue{})
+ dataflowTester.FlushTabler(&models.JiraBoardIssue{})
+ dataflowTester.FlushTabler(&models.JiraSprintIssue{})
+ dataflowTester.FlushTabler(&models.JiraIssueComment{})
+ dataflowTester.FlushTabler(&models.JiraIssueChangelogs{})
+ dataflowTester.FlushTabler(&models.JiraIssueChangelogItems{})
+ dataflowTester.FlushTabler(&models.JiraWorklog{})
+ dataflowTester.FlushTabler(&models.JiraAccount{})
+ dataflowTester.FlushTabler(&models.JiraIssueType{})
+ dataflowTester.FlushTabler(&models.JiraIssueLabel{})
+ dataflowTester.FlushTabler(&models.JiraIssueField{})
+ dataflowTester.Subtask(tasks.ExtractIssueTypesMeta, taskData)
+ dataflowTester.Subtask(tasks.ExtractIssuesMeta, taskData)
+
+ dataflowTester.VerifyTable(
+ models.JiraIssue{},
+ "./snapshot_tables/_tool_jira_issues_for_due_date.csv",
+ e2ehelper.ColumnWithRawData(
+ "connection_id",
+ "issue_id",
+ "project_id",
+ "project_name",
+ "self",
+ "issue_key",
+ "summary",
+ "description",
+ "type",
+ "epic_key",
+ "status_name",
+ "status_key",
+ "story_point",
+ "original_estimate_minutes",
+ "aggregate_estimate_minutes",
+ "remaining_estimate_minutes",
+ "creator_account_id",
+ "creator_account_type",
+ "creator_display_name",
+ "assignee_account_id",
+ "assignee_account_type",
+ "assignee_display_name",
+ "priority_id",
+ "priority_name",
+ "parent_id",
+ "parent_key",
+ "sprint_id",
+ "sprint_name",
+ "resolution_date",
+ "created",
+ "updated",
+ "spent_minutes",
+ "lead_time_minutes",
+ "std_type",
+ "std_status",
+ "icon_url",
+ "changelog_total",
+ "comment_total",
+ "due_date",
+ ),
+ )
+
+ // verify issue conversion
+ dataflowTester.FlushTabler(&ticket.Issue{})
+ dataflowTester.FlushTabler(&ticket.BoardIssue{})
+ dataflowTester.FlushTabler(&ticket.IssueAssignee{})
+ dataflowTester.Subtask(tasks.ConvertIssuesMeta, taskData)
+ dataflowTester.VerifyTable(
+ ticket.Issue{},
+ "./snapshot_tables/issues_for_due_date.csv",
+ []string{
+ "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",
+ "due_date",
+ },
+ )
+
+}
diff --git
a/backend/plugins/jira/e2e/raw_tables/_raw_jira_api_issues_for_due_date.csv
b/backend/plugins/jira/e2e/raw_tables/_raw_jira_api_issues_for_due_date.csv
new file mode 100644
index 000000000..b8e9dd466
--- /dev/null
+++ b/backend/plugins/jira/e2e/raw_tables/_raw_jira_api_issues_for_due_date.csv
@@ -0,0 +1,2 @@
+"id","params","data","url","input","created_at"
+12441,"{""ConnectionId"":2,""BoardId"":8}","{""id"": ""10063"", ""key"":
""EE-1"", ""self"":
""https://merico.atlassian.net/rest/agile/1.0/issue/10063"", ""expand"":
""operations,versionedRepresentations,editmeta,changelog,renderedFields"",
""fields"": {""epic"": null, ""votes"": {""self"":
""https://merico.atlassian.net/rest/api/2/issue/EE-1/votes"", ""votes"": 0,
""hasVoted"": false}, ""labels"": [""frontEnd"",""Saas""], ""sprint"": null,
""status"": {""id"": ""10068"", ""name"": ""已完成 [...]
\ No newline at end of file
diff --git
a/backend/plugins/jira/e2e/snapshot_tables/_tool_jira_issues_for_due_date.csv
b/backend/plugins/jira/e2e/snapshot_tables/_tool_jira_issues_for_due_date.csv
new file mode 100644
index 000000000..e96bc0b2c
--- /dev/null
+++
b/backend/plugins/jira/e2e/snapshot_tables/_tool_jira_issues_for_due_date.csv
@@ -0,0 +1,2 @@
+connection_id,issue_id,project_id,project_name,self,issue_key,summary,description,type,epic_key,status_name,status_key,story_point,original_estimate_minutes,aggregate_estimate_minutes,remaining_estimate_minutes,creator_account_id,creator_account_type,creator_display_name,assignee_account_id,assignee_account_type,assignee_display_name,priority_id,priority_name,parent_id,parent_key,sprint_id,sprint_name,resolution_date,created,updated,spent_minutes,lead_time_minutes,std_type,std_status,ico
[...]
+2,10063,10003,Enterprise
Edition,https://merico.atlassian.net/rest/agile/1.0/issue/10063,EE-1,四个排序图:测试/注释覆盖度、复用度、模块性,,故事,,已完成,new,-1,,1260,0,5e9711ba34f7b90c0fbc37d3,,Rankin
Zheng,5ecfbd0c730ec90c1999cadf,,Dingding
Zhang,3,Medium,0,,0,,2020-06-19T06:31:18.495+00:00,2020-06-12T00:13:13.360+00:00,2021-03-28T08:06:08.713+00:00,,10458,故事,TODO,https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10315?size=medium,25,1,2025-03-03T16:00:00.000+00:00,"{""Connectio
[...]
diff --git a/backend/plugins/jira/e2e/snapshot_tables/issues_for_due_date.csv
b/backend/plugins/jira/e2e/snapshot_tables/issues_for_due_date.csv
new file mode 100644
index 000000000..11e907816
--- /dev/null
+++ b/backend/plugins/jira/e2e/snapshot_tables/issues_for_due_date.csv
@@ -0,0 +1,2 @@
+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,due_date
+jira:JiraIssue:2:10063,https://merico.atlassian.net/browse/EE-1,https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10315?size=medium,EE-1,四个排序图:测试/注释覆盖度、复用度、模块性,,,故事,故事,TODO,已完成,-1,2020-06-19T06:31:18.495+00:00,2020-06-12T00:13:13.360+00:00,2021-03-28T08:06:08.713+00:00,10458,,Medium,,,,jira:JiraAccount:2:5e9711ba34f7b90c0fbc37d3,Rankin
Zheng,jira:JiraAccount:2:5ecfbd0c730ec90c1999cadf,Dingding Zhang,,,Enterprise
Edition,2025-03-03T16:00:00.000+00:00
diff --git a/backend/plugins/jira/models/scope_config.go
b/backend/plugins/jira/models/scope_config.go
index bb267a0f6..c64cd1eb0 100644
--- a/backend/plugins/jira/models/scope_config.go
+++ b/backend/plugins/jira/models/scope_config.go
@@ -44,6 +44,7 @@ type JiraScopeConfig struct {
common.ScopeConfig `mapstructure:",squash" json:",inline"
gorm:"embedded"`
EpicKeyField string
`mapstructure:"epicKeyField,omitempty" json:"epicKeyField"
gorm:"type:varchar(255)"`
StoryPointField string
`mapstructure:"storyPointField,omitempty" json:"storyPointField"
gorm:"type:varchar(255)"`
+ DueDateField string
`mapstructure:"dueDateField,omitempty" json:"dueDateField"
gorm:"type:varchar(255)"`
RemotelinkCommitShaPattern string
`mapstructure:"remotelinkCommitShaPattern,omitempty"
json:"remotelinkCommitShaPattern" gorm:"type:varchar(255)"`
RemotelinkRepoPattern []CommitUrlPattern
`mapstructure:"remotelinkRepoPattern,omitempty" json:"remotelinkRepoPattern"
gorm:"type:json;serializer:json"`
TypeMappings map[string]TypeMapping
`mapstructure:"typeMappings,omitempty" json:"typeMappings"
gorm:"type:json;serializer:json"`
diff --git a/backend/plugins/jira/tasks/apiv2models/issue.go
b/backend/plugins/jira/tasks/apiv2models/issue.go
index 1f33841fd..c62183d0f 100644
--- a/backend/plugins/jira/tasks/apiv2models/issue.go
+++ b/backend/plugins/jira/tasks/apiv2models/issue.go
@@ -19,7 +19,6 @@ package apiv2models
import (
"encoding/json"
- "strings"
"time"
"github.com/apache/incubator-devlake/core/errors"
@@ -279,17 +278,6 @@ func (i Issue) toToolLayer(connectionId uint64)
*models.JiraIssue {
temp := *i.Fields.Timespent / 60
result.SpentMinutes = &temp
}
- if i.Fields.Duedate != "" && i.Fields.Duedate != "null" {
- // get timezone from i.Fields.Created
- duedateStr := strings.Trim(i.Fields.Duedate, "\"")
- // parse due date to time.Time
- if i.Fields.Created != nil {
- loc := i.Fields.Created.ToTime().Location()
- if t, err := time.ParseInLocation("2006-01-02",
duedateStr, loc); err == nil {
- result.DueDate = &t
- }
- }
- }
return result
}
diff --git a/backend/plugins/jira/tasks/issue_extractor.go
b/backend/plugins/jira/tasks/issue_extractor.go
index 11277a0a4..0fd71648a 100644
--- a/backend/plugins/jira/tasks/issue_extractor.go
+++ b/backend/plugins/jira/tasks/issue_extractor.go
@@ -18,12 +18,14 @@ limitations under the License.
package tasks
import (
+ "regexp"
"strconv"
"strings"
"time"
"github.com/apache/incubator-devlake/core/dal"
"github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/models/common"
"github.com/apache/incubator-devlake/core/plugin"
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
"github.com/apache/incubator-devlake/plugins/jira/models"
@@ -143,7 +145,33 @@ func extractIssues(data *JiraTaskData, mappings
*typeMappings, apiIssue *apiv2mo
}
}
-
+ // default due date field is "duedate"
+ dueDateField := "duedate"
+ if data.Options.ScopeConfig != nil &&
data.Options.ScopeConfig.DueDateField != "" {
+ dueDateField = data.Options.ScopeConfig.DueDateField
+ }
+ unknownDueDate := apiIssue.Fields.AllFields[dueDateField]
+ switch dd := unknownDueDate.(type) {
+ case string:
+ // string, try to parse
+ if dd == "" || dd == "null" {
+ break
+ }
+ // use loc of issue.Created if dd is of type date (yyyy-MM-dd)
+ isDateStr, _ := regexp.Match(`\d{4}-\d{2}-\d{2}`, []byte(dd))
+ var temp time.Time
+ if isDateStr {
+ temp, _ = common.ConvertStringToTimeInLoc(dd,
issue.Created.Location())
+ } else {
+ temp, _ = common.ConvertStringToTime(dd)
+ }
+ issue.DueDate = &temp
+ case nil:
+ default:
+ // not string, convert to time.Time, ignore it if failed
+ temp, _ := dd.(time.Time)
+ issue.DueDate = &temp
+ }
// code in next line will set issue.Type to issueType.Name
issue.Type = mappings.TypeIdMappings[issue.Type]
issue.StdType = mappings.StdTypeMappings[issue.Type]