diff --git a/backend/plugins/jira/models/board.go b/backend/plugins/jira/models/board.go
index 267c1cdc0ec..f76af2a5247 100644
--- a/backend/plugins/jira/models/board.go
+++ b/backend/plugins/jira/models/board.go
@@ -34,6 +34,7 @@ type JiraBoard struct {
Self string `json:"self" mapstructure:"self" gorm:"type:varchar(255)"`
Type string `json:"type" mapstructure:"type" gorm:"type:varchar(100)"`
Jql string `json:"jql" mapstructure:"jql"`
+ SubQuery string `json:"subQuery" mapstructure:"subQuery"`
}
func (b JiraBoard) ScopeId() string {
diff --git a/backend/plugins/jira/models/migrationscripts/20260611_add_sub_query_to_boards.go b/backend/plugins/jira/models/migrationscripts/20260611_add_sub_query_to_boards.go
new file mode 100644
index 00000000000..455be7f6df2
--- /dev/null
+++ b/backend/plugins/jira/models/migrationscripts/20260611_add_sub_query_to_boards.go
@@ -0,0 +1,46 @@
+/*
+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/helpers/migrationhelper"
+)
+
+type JiraBoard20260611 struct {
+ SubQuery string
+}
+
+func (JiraBoard20260611) TableName() string {
+ return "_tool_jira_boards"
+}
+
+type addSubQueryToBoards struct{}
+
+func (script *addSubQueryToBoards) Up(basicRes context.BasicRes) errors.Error {
+ return migrationhelper.AutoMigrateTables(basicRes, &JiraBoard20260611{})
+}
+
+func (*addSubQueryToBoards) Version() uint64 {
+ return 20260611140000
+}
+
+func (*addSubQueryToBoards) Name() string {
+ return "add sub_query to _tool_jira_boards"
+}
diff --git a/backend/plugins/jira/models/migrationscripts/register.go b/backend/plugins/jira/models/migrationscripts/register.go
index 9c334a9ef88..37fc6a5f917 100644
--- a/backend/plugins/jira/models/migrationscripts/register.go
+++ b/backend/plugins/jira/models/migrationscripts/register.go
@@ -55,5 +55,6 @@ func All() []plugin.MigrationScript {
new(flushJiraIssues),
new(updateScopeConfig),
new(addFixVersions20250619),
+ new(addSubQueryToBoards),
}
}
diff --git a/backend/plugins/jira/tasks/board_filter_begin_collector.go b/backend/plugins/jira/tasks/board_filter_begin_collector.go
index 6c513f1f83c..10dc60758a8 100644
--- a/backend/plugins/jira/tasks/board_filter_begin_collector.go
+++ b/backend/plugins/jira/tasks/board_filter_begin_collector.go
@@ -41,14 +41,18 @@ func CollectBoardFilterBegin(taskCtx plugin.SubTaskContext) errors.Error {
logger := taskCtx.GetLogger()
db := taskCtx.GetDal()
logger.Info("collect board in collectBoardFilterBegin: %d", data.Options.BoardId)
- // get board filter id
- filterId, err := getBoardFilterId(data)
+
+ boardConfig, err := getBoardConfiguration(data)
if err != nil {
- return errors.Default.Wrap(err, fmt.Sprintf("error getting board filter id for connection_id:%d board_id:%d", data.Options.ConnectionId, data.Options.BoardId))
+ return errors.Default.Wrap(err, fmt.Sprintf("error getting board configuration for connection_id:%d board_id:%d", data.Options.ConnectionId, data.Options.BoardId))
}
+ filterId := boardConfig.Filter.ID
logger.Info("collect board filter:%s", filterId)
- // get board filter jql
+ if boardConfig.SubQuery.Query != "" {
+ logger.Warn(nil, "board %d has kanban sub-filter: %s — using saved filter JQL for collection to avoid silent issue exclusion", data.Options.BoardId, boardConfig.SubQuery.Query)
+ }
+
filterInfo, err := getBoardFilterJql(data, filterId)
if err != nil {
return errors.Default.Wrap(err, fmt.Sprintf("error getting board filter jql for connection_id:%d board_id:%d", data.Options.ConnectionId, data.Options.BoardId))
@@ -62,17 +66,21 @@ func CollectBoardFilterBegin(taskCtx plugin.SubTaskContext) errors.Error {
return errors.Default.Wrap(err, fmt.Sprintf("error finding record in _tool_jira_boards table for connection_id:%d board_id:%d", data.Options.ConnectionId, data.Options.BoardId))
}
+ // Store filter ID and sub-query on task data for downstream subtasks
+ data.FilterId = filterId
+ record.SubQuery = boardConfig.SubQuery.Query
+
// full sync
syncPolicy := taskCtx.TaskContext().SyncPolicy()
if syncPolicy != nil && syncPolicy.FullSync {
if record.Jql != jql {
record.Jql = jql
- err = db.Update(&record, dal.Where("connection_id = ? AND board_id = ? ", data.Options.ConnectionId, data.Options.BoardId))
- if err != nil {
- return errors.Default.Wrap(err, fmt.Sprintf("error updating record in _tool_jira_boards table for connection_id:%d board_id:%d", data.Options.ConnectionId, data.Options.BoardId))
- }
- logger.Info("full sync mode, update jql to %s", record.Jql)
}
+ err = db.Update(&record, dal.Where("connection_id = ? AND board_id = ? ", data.Options.ConnectionId, data.Options.BoardId))
+ if err != nil {
+ return errors.Default.Wrap(err, fmt.Sprintf("error updating record in _tool_jira_boards table for connection_id:%d board_id:%d", data.Options.ConnectionId, data.Options.BoardId))
+ }
+ logger.Info("full sync mode, update jql to %s", record.Jql)
return nil
}
@@ -92,7 +100,6 @@ func CollectBoardFilterBegin(taskCtx plugin.SubTaskContext) errors.Error {
flag := cfg.GetBool("JIRA_JQL_AUTO_FULL_REFRESH")
if flag {
logger.Info("connection_id:%d board_id:%d filter jql has changed, And the previous jql is %s, now jql is %s, run it in fullSync mode", data.Options.ConnectionId, data.Options.BoardId, record.Jql, jql)
- // set full sync
taskCtx.TaskContext().SetSyncPolicy(&coreModels.SyncPolicy{TriggerSyncPolicy: coreModels.TriggerSyncPolicy{FullSync: true}})
record.Jql = jql
err = db.Update(&record, dal.Where("connection_id = ? AND board_id = ? ", data.Options.ConnectionId, data.Options.BoardId))
@@ -102,24 +109,28 @@ func CollectBoardFilterBegin(taskCtx plugin.SubTaskContext) errors.Error {
} else {
return errors.Default.New(fmt.Sprintf("connection_id:%d board_id:%d filter jql has changed, please use fullSync mode. And the previous jql is %s, now jql is %s", data.Options.ConnectionId, data.Options.BoardId, record.Jql, jql))
}
+ } else {
+ // JQL unchanged but sub-query may have changed — persist it
+ err = db.Update(&record, dal.Where("connection_id = ? AND board_id = ? ", data.Options.ConnectionId, data.Options.BoardId))
+ if err != nil {
+ return errors.Default.Wrap(err, fmt.Sprintf("error updating record in _tool_jira_boards table for connection_id:%d board_id:%d", data.Options.ConnectionId, data.Options.BoardId))
+ }
}
- // no change
return nil
}
-func getBoardFilterId(data *JiraTaskData) (string, error) {
+func getBoardConfiguration(data *JiraTaskData) (*BoardConfiguration, error) {
url := fmt.Sprintf("agile/1.0/board/%d/configuration", data.Options.BoardId)
boardConfiguration, err := data.ApiClient.Get(url, nil, nil)
if err != nil {
- return "", err
+ return nil, err
}
bc := &BoardConfiguration{}
err = helper.UnmarshalResponse(boardConfiguration, bc)
if err != nil {
- return "", err
+ return nil, err
}
- filterId := bc.Filter.ID
- return filterId, nil
+ return bc, nil
}
func getBoardFilterJql(data *JiraTaskData, filterId string) (*FilterInfo, error) {
@@ -141,6 +152,9 @@ type BoardConfiguration struct {
Name string `json:"name"`
Type string `json:"type"`
Self string `json:"self"`
+ SubQuery struct {
+ Query string `json:"query"`
+ } `json:"subQuery"`
Location struct {
Type string `json:"type"`
Key string `json:"key"`
diff --git a/backend/plugins/jira/tasks/board_filter_begin_collector_test.go b/backend/plugins/jira/tasks/board_filter_begin_collector_test.go
new file mode 100644
index 00000000000..dc81004a971
--- /dev/null
+++ b/backend/plugins/jira/tasks/board_filter_begin_collector_test.go
@@ -0,0 +1,137 @@
+/*
+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 (
+ "encoding/json"
+ "testing"
+)
+
+func Test_BoardConfiguration_UnmarshalSubQuery(t *testing.T) {
+ tests := []struct {
+ name string
+ raw string
+ wantSubQuery string
+ wantFilterID string
+ wantID int
+ wantName string
+ wantType string
+ wantColumnCount int
+ wantRankFieldID int
+ }{
+ {
+ name: "kanban board with sub-filter object",
+ raw: `{"id":1201,"name":"Squad 5","type":"kanban",` +
+ `"self":"https://example.atlassian.net/rest/agile/1.0/board/1201/configuration",` +
+ `"filter":{"id":"17696","self":"https://example.atlassian.net/rest/api/2/filter/17696"},` +
+ `"subQuery":{"query":"fixVersion in unreleasedVersions() OR fixVersion is EMPTY"},` +
+ `"columnConfig":{"columns":[{"name":"Backlog","statuses":[{"id":"1","self":"https://example.atlassian.net/rest/api/2/status/1"}]},` +
+ `{"name":"Done","statuses":[{"id":"10037","self":"https://example.atlassian.net/rest/api/2/status/10037"}]}],` +
+ `"constraintType":"issueCount"},"ranking":{"rankCustomFieldId":10019}}`,
+ wantSubQuery: "fixVersion in unreleasedVersions() OR fixVersion is EMPTY",
+ wantFilterID: "17696",
+ wantID: 1201,
+ wantName: "Squad 5",
+ wantType: "kanban",
+ wantColumnCount: 2,
+ wantRankFieldID: 10019,
+ },
+ {
+ name: "board without subQuery field",
+ raw: `{"id":500,"name":"No SubFilter Board","type":"scrum",` +
+ `"self":"https://example.atlassian.net/rest/agile/1.0/board/500/configuration",` +
+ `"filter":{"id":"99999","self":"https://example.atlassian.net/rest/api/2/filter/99999"},` +
+ `"columnConfig":{"columns":[],"constraintType":"issueCount"},"ranking":{"rankCustomFieldId":10019}}`,
+ wantSubQuery: "",
+ wantFilterID: "99999",
+ wantID: 500,
+ wantName: "No SubFilter Board",
+ wantType: "scrum",
+ wantColumnCount: 0,
+ wantRankFieldID: 10019,
+ },
+ {
+ name: "board with empty subQuery object",
+ raw: `{"id":600,"name":"Empty SubQuery Board","type":"kanban",` +
+ `"self":"https://example.atlassian.net/rest/agile/1.0/board/600/configuration",` +
+ `"filter":{"id":"11111","self":"https://example.atlassian.net/rest/api/2/filter/11111"},` +
+ `"subQuery":{},` +
+ `"columnConfig":{"columns":[],"constraintType":"issueCount"},"ranking":{"rankCustomFieldId":10019}}`,
+ wantSubQuery: "",
+ wantFilterID: "11111",
+ wantID: 600,
+ wantName: "Empty SubQuery Board",
+ wantType: "kanban",
+ wantColumnCount: 0,
+ wantRankFieldID: 10019,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var bc BoardConfiguration
+ if err := json.Unmarshal([]byte(tt.raw), &bc); err != nil {
+ t.Fatalf("failed to unmarshal BoardConfiguration: %v", err)
+ }
+ if bc.SubQuery.Query != tt.wantSubQuery {
+ t.Errorf("SubQuery.Query = %q, want %q", bc.SubQuery.Query, tt.wantSubQuery)
+ }
+ if bc.Filter.ID != tt.wantFilterID {
+ t.Errorf("Filter.ID = %q, want %q", bc.Filter.ID, tt.wantFilterID)
+ }
+ if bc.ID != tt.wantID {
+ t.Errorf("ID = %d, want %d", bc.ID, tt.wantID)
+ }
+ if bc.Name != tt.wantName {
+ t.Errorf("Name = %q, want %q", bc.Name, tt.wantName)
+ }
+ if bc.Type != tt.wantType {
+ t.Errorf("Type = %q, want %q", bc.Type, tt.wantType)
+ }
+ if len(bc.ColumnConfig.Columns) != tt.wantColumnCount {
+ t.Errorf("ColumnConfig.Columns length = %d, want %d", len(bc.ColumnConfig.Columns), tt.wantColumnCount)
+ }
+ if bc.Ranking.RankCustomFieldID != tt.wantRankFieldID {
+ t.Errorf("Ranking.RankCustomFieldID = %d, want %d", bc.Ranking.RankCustomFieldID, tt.wantRankFieldID)
+ }
+ })
+ }
+}
+
+func Test_BoardConfiguration_FullJiraCloudResponse(t *testing.T) {
+ // Exact response payload from Jira Cloud for Board 1201 (Squad 5)
+ raw := `{"id":1201,"name":"Squad 5","type":"kanban","self":"https://rakutenadvertising.atlassian.net/rest/agile/1.0/board/1201/configuration","location":{"type":"user","id":"62d8159bb2e6b1992b5be875","self":"https://rakutenadvertising.atlassian.net/rest/api/2/user?accountId=62d8159bb2e6b1992b5be875"},"filter":{"id":"17696","self":"https://rakutenadvertising.atlassian.net/rest/api/2/filter/17696"},"subQuery":{"query":"fixVersion in unreleasedVersions() OR fixVersion is EMPTY"},"columnConfig":{"columns":[{"name":"Backlog","statuses":[{"id":"1","self":"https://rakutenadvertising.atlassian.net/rest/api/2/status/1"},{"id":"4","self":"https://rakutenadvertising.atlassian.net/rest/api/2/status/4"},{"id":"10016","self":"https://rakutenadvertising.atlassian.net/rest/api/2/status/10016"},{"id":"10003","self":"https://rakutenadvertising.atlassian.net/rest/api/2/status/10003"}]},{"name":"To Do","statuses":[{"id":"10054","self":"https://rakutenadvertising.atlassian.net/rest/api/2/status/10054"}]},{"name":"Blocked","statuses":[{"id":"10019","self":"https://rakutenadvertising.atlassian.net/rest/api/2/status/10019"}]},{"name":"In Development","statuses":[{"id":"10017","self":"https://rakutenadvertising.atlassian.net/rest/api/2/status/10017"},{"id":"10177","self":"https://rakutenadvertising.atlassian.net/rest/api/2/status/10177"},{"id":"10038","self":"https://rakutenadvertising.atlassian.net/rest/api/2/status/10038"}]},{"name":"Code Review","statuses":[{"id":"10024","self":"https://rakutenadvertising.atlassian.net/rest/api/2/status/10024"}]},{"name":"Ready for QA","statuses":[{"id":"10029","self":"https://rakutenadvertising.atlassian.net/rest/api/2/status/10029"},{"id":"10033","self":"https://rakutenadvertising.atlassian.net/rest/api/2/status/10033"}]},{"name":"In QA","statuses":[{"id":"10018","self":"https://rakutenadvertising.atlassian.net/rest/api/2/status/10018"},{"id":"10158","self":"https://rakutenadvertising.atlassian.net/rest/api/2/status/10158"}]},{"name":"Done","statuses":[{"id":"10037","self":"https://rakutenadvertising.atlassian.net/rest/api/2/status/10037"}]}],"constraintType":"issueCount"},"ranking":{"rankCustomFieldId":10019}}`
+
+ var bc BoardConfiguration
+ if err := json.Unmarshal([]byte(raw), &bc); err != nil {
+ t.Fatalf("failed to unmarshal real Jira Cloud response: %v", err)
+ }
+
+ if bc.SubQuery.Query != "fixVersion in unreleasedVersions() OR fixVersion is EMPTY" {
+ t.Errorf("SubQuery.Query = %q, want the fixVersion sub-filter", bc.SubQuery.Query)
+ }
+ if bc.Filter.ID != "17696" {
+ t.Errorf("Filter.ID = %q, want %q", bc.Filter.ID, "17696")
+ }
+ if len(bc.ColumnConfig.Columns) != 8 {
+ t.Errorf("ColumnConfig.Columns length = %d, want 8", len(bc.ColumnConfig.Columns))
+ }
+ if bc.Location.ID != "62d8159bb2e6b1992b5be875" {
+ t.Errorf("Location.ID = %q, want %q", bc.Location.ID, "62d8159bb2e6b1992b5be875")
+ }
+}
diff --git a/backend/plugins/jira/tasks/board_filter_end_collector.go b/backend/plugins/jira/tasks/board_filter_end_collector.go
index 65d8eca14fd..4dfe06fefea 100644
--- a/backend/plugins/jira/tasks/board_filter_end_collector.go
+++ b/backend/plugins/jira/tasks/board_filter_end_collector.go
@@ -40,14 +40,13 @@ func CollectBoardFilterEnd(taskCtx plugin.SubTaskContext) errors.Error {
db := taskCtx.GetDal()
logger.Info("collect board in collectBoardFilterEnd: %d", data.Options.BoardId)
- // get board filter id
- filterId, err := getBoardFilterId(data)
+ boardConfig, err := getBoardConfiguration(data)
if err != nil {
- return errors.Default.Wrap(err, fmt.Sprintf("error getting board filter id for connection_id:%d board_id:%d", data.Options.ConnectionId, data.Options.BoardId))
+ return errors.Default.Wrap(err, fmt.Sprintf("error getting board configuration for connection_id:%d board_id:%d", data.Options.ConnectionId, data.Options.BoardId))
}
+ filterId := boardConfig.Filter.ID
logger.Info("collect board filter:%s", filterId)
- // get board filter jql
filterInfo, err := getBoardFilterJql(data, filterId)
if err != nil {
return errors.Default.Wrap(err, fmt.Sprintf("error getting board filter jql for connection_id:%d board_id:%d", data.Options.ConnectionId, data.Options.BoardId))
@@ -55,7 +54,6 @@ func CollectBoardFilterEnd(taskCtx plugin.SubTaskContext) errors.Error {
jql := filterInfo.Jql
logger.Info("collect board filter jql:%s", jql)
- // should not change
var record models.JiraBoard
err = db.First(&record, dal.Where("connection_id = ? AND board_id = ? ", data.Options.ConnectionId, data.Options.BoardId))
if err != nil {
@@ -63,12 +61,21 @@ func CollectBoardFilterEnd(taskCtx plugin.SubTaskContext) errors.Error {
}
logger.Info("get board filter jql:%s", record.Jql)
+ cfg := taskCtx.GetConfigReader()
+ autoRefresh := cfg.GetBool("JIRA_JQL_AUTO_FULL_REFRESH")
+
if record.Jql != jql {
- cfg := taskCtx.GetConfigReader()
- flag := cfg.GetBool("JIRA_JQL_AUTO_FULL_REFRESH")
- if !flag {
+ if !autoRefresh {
return errors.Default.New(fmt.Sprintf("connection_id:%d board_id:%d filter jql has changed, please use fullSync mode. And the previous jql is %s, now jql is %s", data.Options.ConnectionId, data.Options.BoardId, record.Jql, jql))
}
+ logger.Warn(nil, "connection_id:%d board_id:%d filter jql changed during collection (previous: %s, now: %s)", data.Options.ConnectionId, data.Options.BoardId, record.Jql, jql)
+ }
+
+ if record.SubQuery != boardConfig.SubQuery.Query {
+ logger.Warn(nil, "connection_id:%d board_id:%d board sub-filter changed during collection (previous: %s, now: %s)", data.Options.ConnectionId, data.Options.BoardId, record.SubQuery, boardConfig.SubQuery.Query)
+ if !autoRefresh {
+ return errors.Default.New(fmt.Sprintf("connection_id:%d board_id:%d board sub-filter has changed during collection, please use fullSync mode. Previous sub-filter: %s, now: %s", data.Options.ConnectionId, data.Options.BoardId, record.SubQuery, boardConfig.SubQuery.Query))
+ }
}
return nil
diff --git a/backend/plugins/jira/tasks/issue_collector.go b/backend/plugins/jira/tasks/issue_collector.go
index 9a361cbbcca..28aa473ff80 100644
--- a/backend/plugins/jira/tasks/issue_collector.go
+++ b/backend/plugins/jira/tasks/issue_collector.go
@@ -27,11 +27,10 @@ import (
"time"
"github.com/apache/incubator-devlake/core/dal"
- "github.com/apache/incubator-devlake/plugins/jira/models"
-
"github.com/apache/incubator-devlake/core/errors"
"github.com/apache/incubator-devlake/core/plugin"
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/jira/models"
)
const RAW_ISSUE_TABLE = "jira_api_issues"
@@ -51,78 +50,83 @@ func CollectIssues(taskCtx plugin.SubTaskContext) errors.Error {
logger := taskCtx.GetLogger()
apiCollector, err := api.NewStatefulApiCollector(api.RawDataSubTaskArgs{
Ctx: taskCtx,
- /*
- This struct will be JSONEncoded and stored into database along with raw data itself, to identity minimal
- set of data to be process, for example, we process JiraIssues by Board
- */
Params: JiraApiParams{
ConnectionId: data.Options.ConnectionId,
BoardId: data.Options.BoardId,
},
- /*
- Table store raw data
- */
Table: RAW_ISSUE_TABLE,
})
if err != nil {
return err
}
- // build jql
- // IMPORTANT: we have to keep paginated data in a consistence order to avoid data-missing, if we sort issues by
- // `updated`, issue will be jumping between pages if it got updated during the collection process
+ // IMPORTANT: we sort by `created ASC` to keep paginated data in a consistent order.
+ // Sorting by `updated` would cause issues to jump between pages during collection.
loc, err := getTimeZone(taskCtx)
if err != nil {
logger.Info("failed to get timezone, err: %v", err)
} else {
logger.Info("got user's timezone: %v", loc.String())
}
- jql := "ORDER BY created ASC"
+ incrementalJql := "ORDER BY created ASC"
if apiCollector.GetSince() != nil {
- jql = buildJQL(*apiCollector.GetSince(), loc)
- }
-
- err = apiCollector.InitCollector(api.ApiCollectorArgs{
- ApiClient: data.ApiClient,
- PageSize: data.Options.PageSize,
- /*
- url may use arbitrary variables from different connection in any order, we need GoTemplate to allow more
- flexible for all kinds of possibility.
- Pager contains information for a particular page, calculated by ApiCollector, and will be passed into
- GoTemplate to generate a url for that page.
- We want to do page-fetching in ApiCollector, because the logic are highly similar, by doing so, we can
- avoid duplicate logic for every tasks, and when we have a better idea like improving performance, we can
- do it in one place
- */
- UrlTemplate: "agile/1.0/board/{{ .Params.BoardId }}/issue",
- /*
- (Optional) Return query string for request, or you can plug them into UrlTemplate directly
- */
+ incrementalJql = buildJQL(*apiCollector.GetSince(), loc)
+ }
+
+ // Use the search API with `filter = {id}` JQL instead of the board Agile API.
+ // The board Agile API applies kanban sub-filters server-side, which silently
+ // excludes resolved issues (e.g. those with a released fixVersion).
+ // The search API with the saved filter JQL returns all matching issues.
+ filterJql := buildFilterJQL(data.FilterId, incrementalJql)
+ logger.Info("collecting issues via search API with JQL: %s", filterJql)
+
+ pageSize := data.Options.PageSize
+ if pageSize == 0 {
+ pageSize = 100
+ }
+
+ if strings.EqualFold(string(data.JiraServerInfo.DeploymentType), string(models.DeploymentServer)) {
+ logger.Info("Using api/2/search for JIRA Server issue collection")
+ err = setupIssueV2Collector(apiCollector, data, filterJql, pageSize)
+ } else {
+ logger.Info("Using api/3/search/jql for JIRA Cloud issue collection")
+ err = setupIssueV3Collector(apiCollector, data, filterJql, pageSize)
+ }
+ if err != nil {
+ return err
+ }
+
+ return apiCollector.Execute()
+}
+
+func buildFilterJQL(filterId string, incrementalJql string) string {
+ if filterId == "" {
+ return incrementalJql
+ }
+ // Use Jira's `filter = {id}` syntax to reference the saved filter.
+ // This avoids parenthesization bugs when composing raw JQL strings
+ // that may contain OR/AND operators.
+ if incrementalJql == "ORDER BY created ASC" {
+ return fmt.Sprintf("filter = %s ORDER BY created ASC", filterId)
+ }
+ // incrementalJql contains "updated >= '...' ORDER BY created ASC"
+ // We need to insert the filter reference before the incremental clause
+ return fmt.Sprintf("filter = %s AND %s", filterId, incrementalJql)
+}
+
+func setupIssueV2Collector(apiCollector *api.StatefulApiCollector, data *JiraTaskData, filterJql string, pageSize int) errors.Error {
+ return apiCollector.InitCollector(api.ApiCollectorArgs{
+ ApiClient: data.ApiClient,
+ PageSize: pageSize,
+ UrlTemplate: "api/2/search",
Query: func(reqData *api.RequestData) (url.Values, errors.Error) {
query := url.Values{}
- query.Set("jql", jql)
+ query.Set("jql", filterJql)
query.Set("startAt", fmt.Sprintf("%v", reqData.Pager.Skip))
query.Set("maxResults", fmt.Sprintf("%v", reqData.Pager.Size))
query.Set("expand", "changelog")
return query, nil
},
- /*
- Some api might do pagination by http headers
- */
- //Header: func(pager *plugin.Pager) http.Header {
- //},
- /*
- Sometimes, we need to collect data based on previous collected data, like jira changelog, it requires
- issue_id as part of the url.
- We can mimic `stdin` design, to accept a `Input` function which produces a `Iterator`, collector
- should iterate all records, and do data-fetching for each on, either in parallel or sequential order
- UrlTemplate: "api/3/issue/{{ Input.ID }}/changelog"
- */
- //Input: databaseIssuesIterator,
- /*
- For api endpoint that returns number of total pages, ApiCollector can collect pages in parallel with ease,
- or other techniques are required if this information was missing.
- */
GetTotalPages: GetTotalPagesFromResponse,
Concurrency: 10,
ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) {
@@ -140,11 +144,40 @@ func CollectIssues(taskCtx plugin.SubTaskContext) errors.Error {
return data.Issues, nil
},
})
- if err != nil {
- return err
- }
+}
- return apiCollector.Execute()
+func setupIssueV3Collector(apiCollector *api.StatefulApiCollector, data *JiraTaskData, filterJql string, pageSize int) errors.Error {
+ return apiCollector.InitCollector(api.ApiCollectorArgs{
+ ApiClient: data.ApiClient,
+ PageSize: pageSize,
+ UrlTemplate: "api/3/search/jql",
+ GetNextPageCustomData: getNextPageCustomDataForV3,
+ Query: func(reqData *api.RequestData) (url.Values, errors.Error) {
+ query := url.Values{}
+ query.Set("jql", filterJql)
+ query.Set("maxResults", fmt.Sprintf("%v", reqData.Pager.Size))
+ query.Set("expand", "changelog")
+ query.Set("fields", "*all")
+ if reqData.CustomData != nil {
+ query.Set("nextPageToken", reqData.CustomData.(string))
+ }
+ return query, nil
+ },
+ ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) {
+ var data struct {
+ Issues []json.RawMessage `json:"issues"`
+ }
+ blob, err := io.ReadAll(res.Body)
+ if err != nil {
+ return nil, errors.Convert(err)
+ }
+ err = json.Unmarshal(blob, &data)
+ if err != nil {
+ return nil, errors.Convert(err)
+ }
+ return data.Issues, nil
+ },
+ })
}
// buildJQL build jql based on timeAfter and incremental mode
diff --git a/backend/plugins/jira/tasks/issue_collector_test.go b/backend/plugins/jira/tasks/issue_collector_test.go
index 99bf5a53367..7d5bdc1c1c9 100644
--- a/backend/plugins/jira/tasks/issue_collector_test.go
+++ b/backend/plugins/jira/tasks/issue_collector_test.go
@@ -61,3 +61,45 @@ func Test_buildJQL(t *testing.T) {
})
}
}
+
+func Test_buildFilterJQL(t *testing.T) {
+ tests := []struct {
+ name string
+ filterId string
+ incrementalJql string
+ want string
+ }{
+ {
+ name: "full sync with filter",
+ filterId: "12345",
+ incrementalJql: "ORDER BY created ASC",
+ want: "filter = 12345 ORDER BY created ASC",
+ },
+ {
+ name: "incremental sync with filter",
+ filterId: "12345",
+ incrementalJql: "updated >= '2021/02/05 12:05' ORDER BY created ASC",
+ want: "filter = 12345 AND updated >= '2021/02/05 12:05' ORDER BY created ASC",
+ },
+ {
+ name: "empty filter id falls back to incremental only",
+ filterId: "",
+ incrementalJql: "ORDER BY created ASC",
+ want: "ORDER BY created ASC",
+ },
+ {
+ name: "empty filter id with incremental clause",
+ filterId: "",
+ incrementalJql: "updated >= '2024/01/01 00:00' ORDER BY created ASC",
+ want: "updated >= '2024/01/01 00:00' ORDER BY created ASC",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := buildFilterJQL(tt.filterId, tt.incrementalJql); got != tt.want {
+ t.Errorf("buildFilterJQL() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/backend/plugins/jira/tasks/task_data.go b/backend/plugins/jira/tasks/task_data.go
index 1b0580396c5..bfab9704f04 100644
--- a/backend/plugins/jira/tasks/task_data.go
+++ b/backend/plugins/jira/tasks/task_data.go
@@ -37,6 +37,7 @@ type JiraTaskData struct {
Options *JiraOptions
ApiClient *api.ApiAsyncClient
JiraServerInfo models.JiraServerInfo
+ FilterId string
}
type JiraApiParams models.JiraApiParams
diff --git a/config-ui/src/plugins/components/connection-form/index.tsx b/config-ui/src/plugins/components/connection-form/index.tsx
index b61c1d5d076..39f4cf0df9d 100644
--- a/config-ui/src/plugins/components/connection-form/index.tsx
+++ b/config-ui/src/plugins/components/connection-form/index.tsx
@@ -109,7 +109,7 @@ export const ConnectionForm = ({ plugin, connectionId, onSuccess }: Props) => {
const {
name,
- connection: { docLink, fields, initialValues },
+ connection: { docLink, fields, initialValues = {} },
} = getPluginConfig(plugin) ?? {};
const disabled = useMemo(() => {
diff --git a/config-ui/src/plugins/components/connection-list/index.tsx b/config-ui/src/plugins/components/connection-list/index.tsx
index e0985b1d14d..2b253f25eb1 100644
--- a/config-ui/src/plugins/components/connection-list/index.tsx
+++ b/config-ui/src/plugins/components/connection-list/index.tsx
@@ -113,7 +113,7 @@ export const ConnectionList = ({ plugin, onCreate }: Props) => {
dataSource={connections}
pagination={false}
/>
- } onClick={onCreate}>
+ } onClick={() => onCreate()}>
Create a New Connection