Skip to content

Commit cd7bebf

Browse files
authored
feat(jira): Allow extra JQL when querying JIRA board (#8972)
* feat(jira): Allow extra JQL when querying JIRA board * fix: revert code so we just do extrajql, no dynamic dev lake project name * fix: remove reference to devlakeprojectname as a template option
1 parent ee919fa commit cd7bebf

9 files changed

Lines changed: 259 additions & 15 deletions

File tree

backend/plugins/jira/impl/impl.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,8 @@ func (p Jira) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]int
195195
return nil, errors.Default.Wrap(err, "failed to create jira api client")
196196
}
197197

198+
var scope *models.JiraBoard
198199
if op.BoardId != 0 {
199-
var scope *models.JiraBoard
200200
// support v100 & advance mode
201201
// If we still cannot find the record in db, we have to request from remote server and save it to db
202202
db := taskCtx.GetDal()
@@ -249,6 +249,7 @@ func (p Jira) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]int
249249
Options: &op,
250250
ApiClient: jiraApiClient,
251251
JiraServerInfo: *info,
252+
Board: scope,
252253
}
253254

254255
return taskData, nil
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package migrationscripts
19+
20+
import (
21+
"github.com/apache/incubator-devlake/core/context"
22+
"github.com/apache/incubator-devlake/core/errors"
23+
"github.com/apache/incubator-devlake/helpers/migrationhelper"
24+
)
25+
26+
type JiraScopeConfig20260702 struct {
27+
ExtraJQL string `gorm:"type:varchar(255)"`
28+
}
29+
30+
func (JiraScopeConfig20260702) TableName() string {
31+
return "_tool_jira_scope_configs"
32+
}
33+
34+
type addExtraJQLToScopeConfig struct{}
35+
36+
func (script *addExtraJQLToScopeConfig) Up(basicRes context.BasicRes) errors.Error {
37+
return migrationhelper.AutoMigrateTables(basicRes, &JiraScopeConfig20260702{})
38+
}
39+
40+
func (*addExtraJQLToScopeConfig) Version() uint64 {
41+
return 20260702000000
42+
}
43+
44+
func (*addExtraJQLToScopeConfig) Name() string {
45+
return "add extra_jql to _tool_jira_scope_configs"
46+
}

backend/plugins/jira/models/migrationscripts/register.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,6 @@ func All() []plugin.MigrationScript {
5656
new(updateScopeConfig),
5757
new(addFixVersions20250619),
5858
new(addSubQueryToBoards),
59+
new(addExtraJQLToScopeConfig),
5960
}
6061
}

backend/plugins/jira/models/scope_config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package models
1919

2020
import (
2121
"regexp"
22+
"text/template"
2223

2324
"github.com/apache/incubator-devlake/core/errors"
2425
"github.com/apache/incubator-devlake/core/models/common"
@@ -49,6 +50,7 @@ type JiraScopeConfig struct {
4950
TypeMappings map[string]TypeMapping `mapstructure:"typeMappings,omitempty" json:"typeMappings" gorm:"type:json;serializer:json"`
5051
ApplicationType string `mapstructure:"applicationType,omitempty" json:"applicationType" gorm:"type:varchar(255)"`
5152
DueDateField string `mapstructure:"dueDateField,omitempty" json:"dueDateField" gorm:"type:varchar(255)"`
53+
ExtraJQL string `mapstructure:"extraJql,omitempty" json:"extraJql" gorm:"type:varchar(255)"`
5254
}
5355

5456
func (r *JiraScopeConfig) SetConnectionId(c *JiraScopeConfig, connectionId uint64) {
@@ -73,6 +75,11 @@ func (r *JiraScopeConfig) Validate() errors.Error {
7375
return errors.Convert(err)
7476
}
7577
}
78+
if r.ExtraJQL != "" {
79+
if _, tmplErr := template.New("extraJql").Funcs(template.FuncMap{}).Option("missingkey=error").Parse(r.ExtraJQL); tmplErr != nil {
80+
return errors.BadInput.Wrap(errors.Convert(tmplErr), "invalid ExtraJQL template")
81+
}
82+
}
7683
return nil
7784
}
7885

backend/plugins/jira/tasks/issue_collector.go

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ limitations under the License.
1818
package tasks
1919

2020
import (
21+
"bytes"
2122
"encoding/json"
2223
"fmt"
2324
"io"
2425
"net/http"
2526
"net/url"
2627
"strings"
28+
"text/template"
2729
"time"
2830

2931
"github.com/apache/incubator-devlake/core/dal"
@@ -77,7 +79,15 @@ func CollectIssues(taskCtx plugin.SubTaskContext) errors.Error {
7779
// The board Agile API applies kanban sub-filters server-side, which silently
7880
// excludes resolved issues (e.g. those with a released fixVersion).
7981
// The search API with the saved filter JQL returns all matching issues.
80-
filterJql := buildFilterJQL(data.FilterId, incrementalJql)
82+
var extraJql string
83+
if data.Options.ScopeConfig != nil && data.Options.ScopeConfig.ExtraJQL != "" {
84+
renderedJql, renderErr := renderExtraJQL(data.Options.ScopeConfig.ExtraJQL, data)
85+
if renderErr != nil {
86+
return renderErr
87+
}
88+
extraJql = renderedJql
89+
}
90+
filterJql := buildFilterJQL(data.FilterId, extraJql, incrementalJql)
8191
logger.Info("collecting issues via search API with JQL: %s", filterJql)
8292

8393
pageSize := data.Options.PageSize
@@ -99,19 +109,71 @@ func CollectIssues(taskCtx plugin.SubTaskContext) errors.Error {
99109
return apiCollector.Execute()
100110
}
101111

102-
func buildFilterJQL(filterId string, incrementalJql string) string {
103-
if filterId == "" {
104-
return incrementalJql
112+
// JqlTemplateData holds the variables available inside an ExtraJQL template.
113+
// Users reference these with Go template syntax, e.g. `{{.BoardName}}`.
114+
type JqlTemplateData struct {
115+
BoardId uint64 // numeric ID of the connected Jira board
116+
BoardName string // display name of the connected Jira board
117+
}
118+
119+
// renderExtraJQL executes the ExtraJQL scope-config field as a Go text/template,
120+
// substituting board-level variables so the same scope config can produce
121+
// different JQL for different boards.
122+
//
123+
// The template is parsed with an empty FuncMap (no built-in helpers such as
124+
// printf) and missingkey=error so that typos in variable names produce an
125+
// explicit error rather than silently rendering "<no value>".
126+
func renderExtraJQL(tmplStr string, data *JiraTaskData) (string, errors.Error) {
127+
tmpl, err := template.New("extraJql").
128+
Funcs(template.FuncMap{}).
129+
Option("missingkey=error").
130+
Parse(tmplStr)
131+
if err != nil {
132+
return "", errors.BadInput.Wrap(err, "invalid ExtraJQL template")
105133
}
106-
// Use Jira's `filter = {id}` syntax to reference the saved filter.
107-
// This avoids parenthesization bugs when composing raw JQL strings
108-
// that may contain OR/AND operators.
109-
if incrementalJql == "ORDER BY created ASC" {
110-
return fmt.Sprintf("filter = %s ORDER BY created ASC", filterId)
134+
135+
vars := JqlTemplateData{
136+
BoardId: data.Options.BoardId,
137+
}
138+
if data.Board != nil {
139+
vars.BoardName = data.Board.Name
140+
}
141+
142+
var buf bytes.Buffer
143+
if execErr := tmpl.Execute(&buf, vars); execErr != nil {
144+
return "", errors.BadInput.Wrap(execErr, "failed to render ExtraJQL template")
145+
}
146+
return buf.String(), nil
147+
}
148+
149+
// buildFilterJQL composes a final JQL query from three inputs:
150+
// - filterId: a Jira saved-filter ID (referenced via `filter = {id}`)
151+
// - extraJql: optional user-supplied JQL fragment appended as an AND condition
152+
// (e.g. `project = "MyComponent"`) to scope a large board down to one project
153+
// - incrementalJql: the time-based clause generated by buildJQL, always ending
154+
// with "ORDER BY created ASC"
155+
//
156+
// extraJql is wrapped in parentheses so that any OR/NOT operators inside it
157+
// do not interfere with the surrounding AND chain.
158+
func buildFilterJQL(filterId string, extraJql string, incrementalJql string) string {
159+
const orderBy = "ORDER BY created ASC"
160+
161+
var conditions []string
162+
if filterId != "" {
163+
conditions = append(conditions, fmt.Sprintf("filter = %s", filterId))
164+
}
165+
if extraJql != "" {
166+
conditions = append(conditions, fmt.Sprintf("(%s)", extraJql))
167+
}
168+
if incrementalJql != orderBy {
169+
// strip the trailing " ORDER BY created ASC" to isolate the time condition
170+
conditions = append(conditions, strings.TrimSuffix(incrementalJql, " "+orderBy))
171+
}
172+
173+
if len(conditions) == 0 {
174+
return orderBy
111175
}
112-
// incrementalJql contains "updated >= '...' ORDER BY created ASC"
113-
// We need to insert the filter reference before the incremental clause
114-
return fmt.Sprintf("filter = %s AND %s", filterId, incrementalJql)
176+
return strings.Join(conditions, " AND ") + " " + orderBy
115177
}
116178

117179
func setupIssueV2Collector(apiCollector *api.StatefulApiCollector, data *JiraTaskData, filterJql string, pageSize int) errors.Error {

backend/plugins/jira/tasks/issue_collector_test.go

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ package tasks
2020
import (
2121
"testing"
2222
"time"
23+
24+
"github.com/apache/incubator-devlake/plugins/jira/models"
2325
)
2426

2527
func Test_buildJQL(t *testing.T) {
@@ -66,6 +68,7 @@ func Test_buildFilterJQL(t *testing.T) {
6668
tests := []struct {
6769
name string
6870
filterId string
71+
extraJql string
6972
incrementalJql string
7073
want string
7174
}{
@@ -93,13 +96,115 @@ func Test_buildFilterJQL(t *testing.T) {
9396
incrementalJql: "updated >= '2024/01/01 00:00' ORDER BY created ASC",
9497
want: "updated >= '2024/01/01 00:00' ORDER BY created ASC",
9598
},
99+
{
100+
name: "extra jql with filter full sync",
101+
filterId: "12345",
102+
extraJql: `project = "MyComponent"`,
103+
incrementalJql: "ORDER BY created ASC",
104+
want: `filter = 12345 AND (project = "MyComponent") ORDER BY created ASC`,
105+
},
106+
{
107+
name: "extra jql with filter incremental sync",
108+
filterId: "12345",
109+
extraJql: `project = "MyComponent"`,
110+
incrementalJql: "updated >= '2024/01/01 00:00' ORDER BY created ASC",
111+
want: `filter = 12345 AND (project = "MyComponent") AND updated >= '2024/01/01 00:00' ORDER BY created ASC`,
112+
},
113+
{
114+
name: "extra jql without filter",
115+
filterId: "",
116+
extraJql: `project = "MyComponent"`,
117+
incrementalJql: "ORDER BY created ASC",
118+
want: `(project = "MyComponent") ORDER BY created ASC`,
119+
},
120+
{
121+
name: "extra jql without filter, incremental sync",
122+
filterId: "",
123+
extraJql: `project = "MyComponent"`,
124+
incrementalJql: "updated >= '2024/01/01 00:00' ORDER BY created ASC",
125+
want: `(project = "MyComponent") AND updated >= '2024/01/01 00:00' ORDER BY created ASC`,
126+
},
127+
{
128+
name: "extra jql with OR operator is parenthesized",
129+
filterId: "12345",
130+
extraJql: `project = "A" OR project = "B"`,
131+
incrementalJql: "ORDER BY created ASC",
132+
want: `filter = 12345 AND (project = "A" OR project = "B") ORDER BY created ASC`,
133+
},
96134
}
97135

98136
for _, tt := range tests {
99137
t.Run(tt.name, func(t *testing.T) {
100-
if got := buildFilterJQL(tt.filterId, tt.incrementalJql); got != tt.want {
138+
if got := buildFilterJQL(tt.filterId, tt.extraJql, tt.incrementalJql); got != tt.want {
101139
t.Errorf("buildFilterJQL() = %v, want %v", got, tt.want)
102140
}
103141
})
104142
}
105143
}
144+
145+
func Test_renderExtraJQL(t *testing.T) {
146+
makeData := func(boardId uint64, boardName string, _ string) *JiraTaskData {
147+
return &JiraTaskData{
148+
Options: &JiraOptions{BoardId: boardId},
149+
Board: &models.JiraBoard{BoardId: boardId, Name: boardName},
150+
}
151+
}
152+
153+
tests := []struct {
154+
name string
155+
tmpl string
156+
data *JiraTaskData
157+
want string
158+
wantErr bool
159+
}{
160+
{
161+
name: "static JQL passes through unchanged",
162+
tmpl: `project = "MyProject"`,
163+
data: makeData(1, "My Board", ""),
164+
want: `project = "MyProject"`,
165+
},
166+
{
167+
name: "BoardName substitution",
168+
tmpl: `project = "{{.BoardName}}"`,
169+
data: makeData(42, "Team Alpha", ""),
170+
want: `project = "Team Alpha"`,
171+
},
172+
{
173+
name: "BoardId substitution",
174+
tmpl: `cf[10001] = {{.BoardId}}`,
175+
data: makeData(99, "Some Board", ""),
176+
want: `cf[10001] = 99`,
177+
},
178+
{
179+
name: "nil Board falls back to empty BoardName",
180+
tmpl: `project = "{{.BoardName}}"`,
181+
data: &JiraTaskData{Options: &JiraOptions{BoardId: 1}, Board: nil},
182+
want: `project = ""`,
183+
},
184+
{
185+
name: "invalid template returns error",
186+
tmpl: `project = "{{.Unclosed"`,
187+
data: makeData(1, "My Board", ""),
188+
wantErr: true,
189+
},
190+
{
191+
name: "unknown field returns error (missingkey=error)",
192+
tmpl: `component = "{{.Typo}}"`,
193+
data: makeData(1, "My Board", ""),
194+
wantErr: true,
195+
},
196+
}
197+
198+
for _, tt := range tests {
199+
t.Run(tt.name, func(t *testing.T) {
200+
got, err := renderExtraJQL(tt.tmpl, tt.data)
201+
if (err != nil) != tt.wantErr {
202+
t.Errorf("renderExtraJQL() error = %v, wantErr %v", err, tt.wantErr)
203+
return
204+
}
205+
if !tt.wantErr && got != tt.want {
206+
t.Errorf("renderExtraJQL() = %v, want %v", got, tt.want)
207+
}
208+
})
209+
}
210+
}

backend/plugins/jira/tasks/task_data.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type JiraTaskData struct {
3838
ApiClient *api.ApiAsyncClient
3939
JiraServerInfo models.JiraServerInfo
4040
FilterId string
41+
Board *models.JiraBoard
4142
}
4243

4344
type JiraApiParams models.JiraApiParams

config-ui/src/plugins/register/jira/config.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export const JiraConfig: IPluginConfig = {
6464
typeMappings: {},
6565
remotelinkCommitShaPattern: '',
6666
remotelinkRepoPattern: [],
67+
extraJql: '',
6768
},
6869
},
6970
};

0 commit comments

Comments
 (0)