This is an automated email from the ASF dual-hosted git repository.
klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git
The following commit(s) were added to refs/heads/main by this push:
new b34e53a5d feat(jira): add projectname support for extra jql (#9035)
b34e53a5d is described below
commit b34e53a5d3ed57ac70e9ef3c5b6fd9a04001e553
Author: Jon Vaughan <[email protected]>
AuthorDate: Wed Aug 19 16:34:10 2026 +0100
feat(jira): add projectname support for extra jql (#9035)
---
backend/plugins/jira/impl/impl.go | 1 +
backend/plugins/jira/tasks/issue_collector.go | 8 +-
backend/plugins/jira/tasks/issue_collector_test.go | 113 ++++++++++++++++++++-
backend/plugins/jira/tasks/task_data.go | 2 +
backend/server/services/blueprint_makeplan_v200.go | 20 ++++
.../services/blueprint_makeplan_v200_test.go | 63 ++++++++++++
.../src/plugins/register/jira/transformation.tsx | 2 +-
7 files changed, 202 insertions(+), 7 deletions(-)
diff --git a/backend/plugins/jira/impl/impl.go
b/backend/plugins/jira/impl/impl.go
index b27ea9daa..cebb899f2 100644
--- a/backend/plugins/jira/impl/impl.go
+++ b/backend/plugins/jira/impl/impl.go
@@ -255,6 +255,7 @@ func (p Jira) PrepareTaskData(taskCtx plugin.TaskContext,
options map[string]int
ApiClient: jiraApiClient,
JiraServerInfo: *info,
Board: scope,
+ ProjectName: op.ProjectName,
}
return taskData, nil
diff --git a/backend/plugins/jira/tasks/issue_collector.go
b/backend/plugins/jira/tasks/issue_collector.go
index 997a73685..7b13af7c6 100644
--- a/backend/plugins/jira/tasks/issue_collector.go
+++ b/backend/plugins/jira/tasks/issue_collector.go
@@ -118,8 +118,9 @@ func CollectIssues(taskCtx plugin.SubTaskContext)
errors.Error {
// JqlTemplateData holds the variables available inside an ExtraJQL template.
// Users reference these with Go template syntax, e.g. `{{.BoardName}}`.
type JqlTemplateData struct {
- BoardId uint64 // numeric ID of the connected Jira board
- BoardName string // display name of the connected Jira board
+ BoardId uint64 // numeric ID of the connected Jira board
+ BoardName string // display name of the connected Jira board
+ ProjectName string // DevLake project name; empty string if no project
context
}
// renderExtraJQL executes the ExtraJQL scope-config field as a Go
text/template,
@@ -139,7 +140,8 @@ func renderExtraJQL(tmplStr string, data *JiraTaskData)
(string, errors.Error) {
}
vars := JqlTemplateData{
- BoardId: data.Options.BoardId,
+ BoardId: data.Options.BoardId,
+ ProjectName: data.ProjectName,
}
if data.Board != nil {
vars.BoardName = data.Board.Name
diff --git a/backend/plugins/jira/tasks/issue_collector_test.go
b/backend/plugins/jira/tasks/issue_collector_test.go
index 098196f93..fe47050df 100644
--- a/backend/plugins/jira/tasks/issue_collector_test.go
+++ b/backend/plugins/jira/tasks/issue_collector_test.go
@@ -18,11 +18,13 @@ limitations under the License.
package tasks
import (
+ "fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
+ "testing/quick"
"time"
"github.com/apache/incubator-devlake/helpers/unithelper"
@@ -261,10 +263,12 @@ func Test_responseUrl(t *testing.T) {
}
func Test_renderExtraJQL(t *testing.T) {
- makeData := func(boardId uint64, boardName string, _ string)
*JiraTaskData {
+ // Task 3.2 (1): wire the third parameter to set ProjectName on both
structs.
+ makeData := func(boardId uint64, boardName string, projectName string)
*JiraTaskData {
return &JiraTaskData{
- Options: &JiraOptions{BoardId: boardId},
- Board: &models.JiraBoard{BoardId: boardId, Name:
boardName},
+ Options: &JiraOptions{BoardId: boardId,
ProjectName: projectName},
+ Board: &models.JiraBoard{BoardId: boardId, Name:
boardName},
+ ProjectName: projectName,
}
}
@@ -311,6 +315,25 @@ func Test_renderExtraJQL(t *testing.T) {
data: makeData(1, "My Board", ""),
wantErr: true,
},
+ // Task 3.2 (2): ProjectName table-driven test cases.
+ {
+ name: "ProjectName substitution",
+ tmpl: `labels = "{{.ProjectName}}"`,
+ data: makeData(1, "My Board", "My DevLake Project"),
+ want: `labels = "My DevLake Project"`,
+ },
+ {
+ name: "empty ProjectName renders empty string",
+ tmpl: `labels = "{{.ProjectName}}"`,
+ data: makeData(1, "My Board", ""),
+ want: `labels = ""`,
+ },
+ {
+ name: "combined ProjectName and BoardName substitution",
+ tmpl: `component = "{{.ProjectName}}" AND owner =
"{{.BoardName}}"`,
+ data: makeData(1, "My Board", "My Project"),
+ want: `component = "My Project" AND owner = "My Board"`,
+ },
}
for _, tt := range tests {
@@ -325,4 +348,88 @@ func Test_renderExtraJQL(t *testing.T) {
}
})
}
+
+ // Task 3.2 (3): Property 1 — template variable round-trip.
+ // Validates: Requirements 1.2, 1.4, 5.4
+ // Tag: Feature: jira-extrajql-projectname, Property 1: template
variable round-trip
+ t.Run("property: triple-variable round-trip", func(t *testing.T) {
+ f := func(projectName, boardName string, boardId uint64) bool {
+ data := &JiraTaskData{
+ Options: &JiraOptions{BoardId: boardId,
ProjectName: projectName},
+ Board: &models.JiraBoard{BoardId:
boardId, Name: boardName},
+ ProjectName: projectName,
+ }
+ got, err :=
renderExtraJQL(`{{.ProjectName}}-{{.BoardName}}-{{.BoardId}}`, data)
+ if err != nil {
+ return false
+ }
+ want := fmt.Sprintf("%s-%s-%d", projectName, boardName,
boardId)
+ return got == want
+ }
+ if err := quick.Check(f, &quick.Config{MaxCount: 100}); err !=
nil {
+ t.Errorf("Property 1 (triple-variable round-trip)
failed: %v", err)
+ }
+ })
+
+ // Task 3.3: Property 2 — static JQL passthrough.
+ // Validates: Requirements 4.3
+ // Tag: Feature: jira-extrajql-projectname, Property 2: static JQL
passthrough
+ t.Run("property: static JQL passthrough", func(t *testing.T) {
+ f := func(s string) bool {
+ if strings.Contains(s, "{{") {
+ return true // skip: not a static template
+ }
+ data := &JiraTaskData{
+ Options: &JiraOptions{BoardId: 1},
+ Board: &models.JiraBoard{},
+ }
+ got, err := renderExtraJQL(s, data)
+ return err == nil && got == s
+ }
+ if err := quick.Check(f, &quick.Config{MaxCount: 100}); err !=
nil {
+ t.Errorf("Property 2 (static JQL passthrough) failed:
%v", err)
+ }
+ })
+}
+
+// Test_renderExtraJQL_Property1_RoundTrip is a top-level property-based test
for
+// the template variable round-trip property.
+// Validates: Requirements 1.2, 1.4, 5.4
+func Test_renderExtraJQL_Property1_RoundTrip(t *testing.T) {
+ f := func(projectName, boardName string, boardId uint64) bool {
+ data := &JiraTaskData{
+ Options: &JiraOptions{BoardId: boardId},
+ Board: &models.JiraBoard{BoardId: boardId, Name:
boardName},
+ ProjectName: projectName,
+ }
+ got, err :=
renderExtraJQL(`{{.ProjectName}}-{{.BoardName}}-{{.BoardId}}`, data)
+ if err != nil {
+ return false
+ }
+ want := fmt.Sprintf("%s-%s-%d", projectName, boardName, boardId)
+ return got == want
+ }
+ if err := quick.Check(f, &quick.Config{MaxCount: 100}); err != nil {
+ t.Errorf("Property 1 (round-trip) failed: %v", err)
+ }
+}
+
+// Test_renderExtraJQL_Property2_StaticPassthrough is a top-level
property-based test for
+// the static JQL passthrough property.
+// Validates: Requirements 4.3
+func Test_renderExtraJQL_Property2_StaticPassthrough(t *testing.T) {
+ f := func(s string) bool {
+ if strings.Contains(s, "{{") {
+ return true // skip: not a static template
+ }
+ data := &JiraTaskData{
+ Options: &JiraOptions{BoardId: 1},
+ Board: &models.JiraBoard{},
+ }
+ got, err := renderExtraJQL(s, data)
+ return err == nil && got == s
+ }
+ if err := quick.Check(f, &quick.Config{MaxCount: 100}); err != nil {
+ t.Errorf("Property 2 (static passthrough) failed: %v", err)
+ }
}
diff --git a/backend/plugins/jira/tasks/task_data.go
b/backend/plugins/jira/tasks/task_data.go
index 3505bf162..227ad8b7d 100644
--- a/backend/plugins/jira/tasks/task_data.go
+++ b/backend/plugins/jira/tasks/task_data.go
@@ -31,6 +31,7 @@ type JiraOptions struct {
ScopeConfig *models.JiraScopeConfig `json:"scopeConfig"
mapstructure:"scopeConfig"`
ScopeConfigId uint64 `json:"scopeConfigId"
mapstructure:"scopeConfigId"`
PageSize int `json:"pageSize"
mapstructure:"pageSize"`
+ ProjectName string `json:"projectName,omitempty"
mapstructure:"projectName,omitempty"`
}
type JiraTaskData struct {
@@ -39,6 +40,7 @@ type JiraTaskData struct {
JiraServerInfo models.JiraServerInfo
FilterId string
Board *models.JiraBoard
+ ProjectName string // DevLake project name, empty if pipeline has no
project
}
type JiraApiParams models.JiraApiParams
diff --git a/backend/server/services/blueprint_makeplan_v200.go
b/backend/server/services/blueprint_makeplan_v200.go
index bd89d7de3..c7c5aaaaa 100644
--- a/backend/server/services/blueprint_makeplan_v200.go
+++ b/backend/server/services/blueprint_makeplan_v200.go
@@ -95,6 +95,26 @@ func GeneratePlanJsonV200(
}
}
+ // Inject projectName into every data-source task option so plugins can
use it
+ // (e.g. Jira's ExtraJQL {{.ProjectName}}) without needing it threaded
through
+ // their own MakeDataSourcePipelinePlanV200. mapstructure silently
ignores
+ // unknown keys for plugins that don't declare the field.
+ if projectName != "" {
+ for i, plan := range sourcePlans {
+ for j, stage := range plan {
+ for k, task := range stage {
+ if task.Options == nil {
+ task.Options =
make(map[string]interface{})
+ }
+ task.Options["projectName"] =
projectName
+ stage[k] = task
+ }
+ plan[j] = stage
+ }
+ sourcePlans[i] = plan
+ }
+ }
+
// make plans for metric plugins
metricPlans := make([]coreModels.PipelinePlan, len(metrics))
i := 0
diff --git a/backend/server/services/blueprint_makeplan_v200_test.go
b/backend/server/services/blueprint_makeplan_v200_test.go
index 22b53b532..cfd2f49e3 100644
--- a/backend/server/services/blueprint_makeplan_v200_test.go
+++ b/backend/server/services/blueprint_makeplan_v200_test.go
@@ -29,6 +29,7 @@ import (
mockplugin "github.com/apache/incubator-devlake/mocks/core/plugin"
"github.com/apache/incubator-devlake/plugins/org/tasks"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
)
func TestMakePlanV200(t *testing.T) {
@@ -101,3 +102,65 @@ func TestMakePlanV200(t *testing.T) {
assert.Equal(t, expectedPlan, plan)
}
+
+func TestGeneratePlanJsonV200_InjectsProjectName(t *testing.T) {
+ const projectName = "TestInjectsProjectName-project"
+ const pluginName = "TestInjectsProjectName-datasource"
+
+ connId := uint64(42)
+ scopes := []*coreModels.BlueprintScope{
+ {ScopeId: "datasource:Board:42:1"},
+ }
+
+ // The data-source plugin returns a single task with boardId but no
projectName.
+ // GeneratePlanJsonV200 must inject projectName into the task options.
+ pluginOutputPlan := coreModels.PipelinePlan{
+ {
+ {Plugin: pluginName, Options:
map[string]interface{}{"boardId": float64(1)}},
+ },
+ }
+ pluginOutputScopes := []plugin.Scope{}
+
+ ds := new(mockplugin.CompositeDataSourcePluginBlueprintV200)
+ ds.On("MakeDataSourcePipelinePlanV200", connId,
scopes).Return(pluginOutputPlan, pluginOutputScopes, nil)
+
+ // Re-register "org" with a permissive mock because GeneratePlanJsonV200
+ // calls org.MapProject whenever projectName is non-empty.
+ org := new(mockplugin.CompositeProjectMapper)
+ org.On("MapProject", mock.Anything,
mock.Anything).Return(coreModels.PipelinePlan{}, nil)
+
+ plugin.RegisterPlugin(pluginName, ds)
+ plugin.RegisterPlugin("org", org)
+
+ connections := []*coreModels.BlueprintConnection{
+ {PluginName: pluginName, ConnectionId: connId, Scopes: scopes},
+ }
+
+ plan, err := GeneratePlanJsonV200(projectName, connections,
map[string]json.RawMessage{}, false)
+ assert.Nil(t, err)
+
+ // The produced plan contains at least one stage with the data-source
task.
+ // Find the first task that belongs to our data-source plugin and
verify its options.
+ var dsTask *coreModels.PipelineTask
+ for _, stage := range plan {
+ for _, task := range stage {
+ if task.Plugin == pluginName {
+ dsTask = task
+ break
+ }
+ }
+ if dsTask != nil {
+ break
+ }
+ }
+
+ assert.NotNil(t, dsTask, "expected to find a task for plugin %q in the
plan", pluginName)
+ if dsTask != nil {
+ // projectName must have been injected
+ assert.Equal(t, projectName, dsTask.Options["projectName"],
+ "GeneratePlanJsonV200 should inject projectName into
task options")
+ // original options must be preserved
+ assert.Equal(t, float64(1), dsTask.Options["boardId"],
+ "original task options (boardId) must be preserved
alongside the injected key")
+ }
+}
diff --git a/config-ui/src/plugins/register/jira/transformation.tsx
b/config-ui/src/plugins/register/jira/transformation.tsx
index bbfe760db..7115f0698 100644
--- a/config-ui/src/plugins/register/jira/transformation.tsx
+++ b/config-ui/src/plugins/register/jira/transformation.tsx
@@ -269,7 +269,7 @@ const renderCollapseItems = ({
label={
<>
<span>Extra JQL</span>
- <HelpTooltip content="Additional JQL clause ANDed into every
issue query. Supports Go text/template syntax. Available variables:
{{.BoardName}} (Jira board display name), {{.BoardId}} (numeric board ID)." />
+ <HelpTooltip content="Additional JQL clause ANDed into every
issue query. Supports Go text/template syntax. Available variables:
{{.BoardName}} (Jira board display name), {{.BoardId}} (numeric board ID),
{{.ProjectName}} (DevLake project name)." />
</>
}
extra='Tip: use Go template variables to make this dynamic, e.g.
owner = "{{.BoardName}}"'