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}}"'

Reply via email to