From 613682a5bde5c5c20afe912f857d43b819ad47bd Mon Sep 17 00:00:00 2001 From: Joshua Smith Date: Fri, 12 Jun 2026 10:03:33 -0600 Subject: [PATCH 1/3] feat(jira): collect issues via search API to avoid kanban sub-filter exclusion * Switch from board Agile API to search API with saved filter JQL so resolved issues (e.g. released fixVersions) are no longer silently excluded by kanban sub-filters * Add sub_query field to boards model and migration to track kanban sub-filter changes across syncs * Support JIRA Cloud v3 search/jql endpoint alongside v2 for Server --- backend/plugins/jira/models/board.go | 1 + .../20260611_add_sub_query_to_boards.go | 46 ++++++ .../jira/models/migrationscripts/register.go | 1 + .../tasks/board_filter_begin_collector.go | 44 ++++-- .../jira/tasks/board_filter_end_collector.go | 23 ++- backend/plugins/jira/tasks/issue_collector.go | 141 +++++++++++------- .../jira/tasks/issue_collector_test.go | 42 ++++++ backend/plugins/jira/tasks/task_data.go | 1 + 8 files changed, 221 insertions(+), 78 deletions(-) create mode 100644 backend/plugins/jira/models/migrationscripts/20260611_add_sub_query_to_boards.go 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..21f3988286b 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 != "" { + 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) + } + 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 + // 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,7 @@ type BoardConfiguration struct { Name string `json:"name"` Type string `json:"type"` Self string `json:"self"` + SubQuery string `json:"subQuery"` Location struct { Type string `json:"type"` Key string `json:"key"` diff --git a/backend/plugins/jira/tasks/board_filter_end_collector.go b/backend/plugins/jira/tasks/board_filter_end_collector.go index 65d8eca14fd..9e2ad18f3ca 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 { + 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) + 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)) + } } 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 From d1ecdeda7185752546007a002e2c057db01bcb19 Mon Sep 17 00:00:00 2001 From: Joshua Smith Date: Fri, 12 Jun 2026 15:32:25 -0600 Subject: [PATCH 2/3] fix(jira,config-ui): correct SubQuery parsing and connection form crashes * Fix Jira board SubQuery struct to match nested API response shape * Prevent click event from being stored as plugin state in connection list * Default initialValues to empty object when plugin config omits it --- .../plugins/jira/tasks/board_filter_begin_collector.go | 10 ++++++---- .../plugins/jira/tasks/board_filter_end_collector.go | 6 +++--- .../src/plugins/components/connection-form/index.tsx | 2 +- .../src/plugins/components/connection-list/index.tsx | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/backend/plugins/jira/tasks/board_filter_begin_collector.go b/backend/plugins/jira/tasks/board_filter_begin_collector.go index 21f3988286b..10dc60758a8 100644 --- a/backend/plugins/jira/tasks/board_filter_begin_collector.go +++ b/backend/plugins/jira/tasks/board_filter_begin_collector.go @@ -49,8 +49,8 @@ func CollectBoardFilterBegin(taskCtx plugin.SubTaskContext) errors.Error { filterId := boardConfig.Filter.ID logger.Info("collect board filter:%s", filterId) - if boardConfig.SubQuery != "" { - 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) + 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) @@ -68,7 +68,7 @@ func CollectBoardFilterBegin(taskCtx plugin.SubTaskContext) errors.Error { // Store filter ID and sub-query on task data for downstream subtasks data.FilterId = filterId - record.SubQuery = boardConfig.SubQuery + record.SubQuery = boardConfig.SubQuery.Query // full sync syncPolicy := taskCtx.TaskContext().SyncPolicy() @@ -152,7 +152,9 @@ type BoardConfiguration struct { Name string `json:"name"` Type string `json:"type"` Self string `json:"self"` - SubQuery string `json:"subQuery"` + 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_end_collector.go b/backend/plugins/jira/tasks/board_filter_end_collector.go index 9e2ad18f3ca..4dfe06fefea 100644 --- a/backend/plugins/jira/tasks/board_filter_end_collector.go +++ b/backend/plugins/jira/tasks/board_filter_end_collector.go @@ -71,10 +71,10 @@ func CollectBoardFilterEnd(taskCtx plugin.SubTaskContext) errors.Error { 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 { - 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) + 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)) + 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)) } } 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} /> - Date: Fri, 12 Jun 2026 15:36:44 -0600 Subject: [PATCH 3/3] feat (jira) Add BoardConfiguration unmarshal tests * Verify SubQuery parsing for kanban boards with, without, and empty sub-filter objects * Include a test using a full Jira Cloud response payload to validate all fields --- .../board_filter_begin_collector_test.go | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 backend/plugins/jira/tasks/board_filter_begin_collector_test.go 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") + } +}