Skip to content

Commit 9b99a8d

Browse files
yotams123claude
andcommitted
fix(jira): resolve ExtraJQL ProjectName per-run instead of via ambiguous reverse lookup
The original ProjectName resolution reverse-looked-up project_mapping by board, which can't disambiguate when the same board is attached to more than one Devlake project (project_mapping's PK is project_name+table+ row_id) -- exactly the scenario this feature is meant to support. Instead, GeneratePlanJsonV200 now injects "projectName" generically into every data-source task's options at blueprint-plan-build time, the one place in the framework where the running project and its scopes are known together unambiguously. Jira's PrepareTaskData just reads it back, removing the project_mapping/crossdomain/didgen lookup entirely. Also: gofmt the JqlTemplateData struct/literal, wire the previously-unused projectName param in Test_renderExtraJQL's test helper and add coverage for {{.ProjectName}}, add TestMakePlanV200InjectsProjectNameIntoDataSourceTaskOptions, and document {{.ProjectName}} in the config-ui ExtraJQL tooltip. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 90239e1 commit 9b99a8d

8 files changed

Lines changed: 229 additions & 23 deletions

File tree

CODE_REVIEW_NOTES.md

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Code Review Notes: jira ScopeConfigId fix / ExtraJQL ProjectName feature
2+
3+
Review date: 2026-07-30. Scope: two branches based on `main` @ `14a4e5bcd`:
4+
5+
- `fix/jira-blueprint-scope-config` (commit `1a07d1062`)
6+
- `feature/expose-devlake-project-extrajql-variable` (commit `90239e1d9`)
7+
8+
This file records what was found and fixed, so future agents don't have to
9+
re-derive the same context from scratch. All fixes described below were
10+
applied as uncommitted working-tree changes on their respective branch as of
11+
this review; check `git log`/`git status` on each branch to see whether
12+
they've since been committed.
13+
14+
## fix/jira-blueprint-scope-config
15+
16+
**What it does:** `makeDataSourcePipelinePlanV200` in
17+
`backend/plugins/jira/api/blueprint_v200.go` now threads the resolved
18+
`ScopeConfigId` into `JiraTaskOptions`, the way `ArgoCD`/`Linear` already do
19+
in their own `blueprint_v200.go`. Previously Jira relied solely on a runtime
20+
fallback in `impl.go`'s `PrepareTaskData` that re-looks-up the board by
21+
`connection_id + board_id` and copies `scope.ScopeConfigId` if the task
22+
option was zero. That fallback is fragile (e.g. it does nothing if
23+
`op.BoardId == 0`, and is generally an indirect way to get a value that's
24+
already known at plan-build time), so passing it explicitly is a real,
25+
justified fix and matches established plugin convention.
26+
27+
**Findings (fixed):**
28+
1. `gofmt` failure — the new `ScopeConfigId` struct field broke alignment
29+
of the `JiraTaskOptions{}` literal. The repo's `.golangci.yaml` enables
30+
the `gofmt` formatter and CI runs `golangci-lint run`, so this would have
31+
failed CI. Fixed by running `gofmt -w`.
32+
2. Missing regression test — sibling plugins (`linear`, `bitbucket`,
33+
`gitlab`, `azuredevops_go`) all have a `blueprint_v200_test.go`; Linear's
34+
even has `TestMakePipelinePlanV200PassesScopeConfigId`, testing exactly
35+
this scenario. Jira had no such test. Added
36+
`backend/plugins/jira/api/blueprint_v200_test.go` modeled on Linear's,
37+
covering: ScopeConfig.ID takes priority over scope.ScopeConfigId, and
38+
scope.ScopeConfigId is used when ScopeConfig is nil/unconfigured.
39+
40+
## feature/expose-devlake-project-extrajql-variable
41+
42+
**What it does:** Exposes the DevLake *project* name (the logical grouping
43+
of scopes across data sources) as `{{.ProjectName}}` inside the `ExtraJQL`
44+
scope-config template, alongside the existing `{{.BoardName}}` /
45+
`{{.BoardId}}`.
46+
47+
**Important design change made during this review — read before touching
48+
`ProjectName` resolution again:**
49+
50+
The original implementation resolved `ProjectName` in `impl.go`'s
51+
`PrepareTaskData` by reverse-looking-up `project_mapping WHERE
52+
table='boards' AND row_id=<domain id of the board>`. This is broken for the
53+
feature's actual stated purpose (per the requester): **the same Jira board
54+
attached to multiple Devlake projects, each project filtering its own
55+
tickets via ExtraJQL.** `project_mapping`'s primary key is
56+
`(project_name, table, row_id)`, so a shared board has one row *per
57+
project*, and the reverse lookup can't tell which project the *current
58+
pipeline run* belongs to — it just grabs an arbitrary/first row. That means
59+
every project sharing a board would get the same (wrong, for all but one of
60+
them) `{{.ProjectName}}` value, silently.
61+
62+
We looked at fixing this "properly" by threading `projectName` through
63+
`plugin.DataSourcePluginBlueprintV200.MakeDataSourcePipelinePlanV200(...)`,
64+
the interface every datasource plugin implements — but that's a framework
65+
interface change with a large blast radius (~15+ plugins), and was
66+
rejected as too big for this fix.
67+
68+
**Actual fix implemented (small blast radius, no interface changes):**
69+
`backend/server/services/blueprint_makeplan_v200.go`'s `GeneratePlanJsonV200`
70+
already receives `projectName` as a parameter — it's the only place in the
71+
framework where "this pipeline run belongs to project X" and "these are its
72+
scopes" are known together unambiguously. After building `sourcePlans` (the
73+
per-connection plans returned by each plugin's
74+
`MakeDataSourcePipelinePlanV200`), it now generically injects
75+
`task.Options["projectName"] = projectName` into every task, for every
76+
plugin, when `projectName != ""`. This is safe because the mapstructure
77+
decoding used everywhere (`Decode`/`DecodeMapStruct`) silently ignores
78+
unknown map keys by default — plugins that don't declare a matching struct
79+
field simply never see it. (Note: `dora`/`refdiff` already manually put a
80+
`"projectName"` key into their own task options today, via
81+
`MakeMetricPluginPipelinePlanV200`'s own `projectName` parameter — this
82+
generic injection follows the same naming convention, just for
83+
`DataSourcePluginBlueprintV200` plugins that don't get `projectName` on
84+
their interface.)
85+
86+
On the Jira side this actually *simplified* the implementation:
87+
- `backend/plugins/jira/tasks/task_data.go`: `JiraOptions` gained a
88+
`ProjectName string` field (mapstructure tag `projectName,omitempty`),
89+
populated purely by decoding task options — no plugin-side logic needed.
90+
- `backend/plugins/jira/impl/impl.go`: `PrepareTaskData` now just does
91+
`ProjectName: op.ProjectName` when building `JiraTaskData`. The
92+
`project_mapping`/`crossdomain`/`didgen` reverse-lookup code and its
93+
imports were deleted entirely — there's no more ambiguity to handle.
94+
- Test coverage: `backend/server/services/blueprint_makeplan_v200_test.go`
95+
gained `TestMakePlanV200InjectsProjectNameIntoDataSourceTaskOptions`,
96+
asserting the injected key lands in a data-source plugin's task options
97+
alongside its own options, using `mock.Anything` for the "org" plugin's
98+
`MapProject` call (re-registering "org" per-test to avoid cross-test mock
99+
state leaking via the global `plugin` registry — see that file for why).
100+
101+
**Other findings (fixed):**
102+
1. `gofmt` failure — `JqlTemplateData` struct and the `JqlTemplateData{}`
103+
literal in `issue_collector.go` used tabs for alignment instead of
104+
spaces; new `ProjectName` field wasn't aligned either. Fixed with
105+
`gofmt -w`.
106+
2. Zero test coverage for the new variable — `issue_collector_test.go`'s
107+
existing `Test_renderExtraJQL` test has a `makeData` helper whose third
108+
parameter (`_ string`) was unused and looked purpose-built for exactly
109+
this addition, but nothing wired it up. Fixed: the helper now sets
110+
`ProjectName` from that parameter, and new subtests exercise
111+
`{{.ProjectName}}` substitution (present and empty/unmapped board). This
112+
part of the test is still valid after the `ProjectName`-resolution
113+
redesign above, since it tests `renderExtraJQL` given an already-built
114+
`JiraTaskData`, independent of how `ProjectName` gets populated upstream.
115+
3. Config-UI help tooltip
116+
(`config-ui/src/plugins/register/jira/transformation.tsx`) documented
117+
only `{{.BoardName}}`/`{{.BoardId}}`. Updated to also mention
118+
`{{.ProjectName}}` so the feature is discoverable from the UI.
119+
120+
## Verification performed
121+
122+
- `go build ./plugins/jira/...`, `./server/services/...` on both branches
123+
(fix branch built in an isolated git worktree at the time of review).
124+
- `go test ./plugins/jira/api/... ./plugins/jira/tasks/... ./plugins/jira/impl/... ./server/services/...`
125+
on both branches. Pre-existing `plugins/jira/e2e` requires `E2E_DB_URL`
126+
and is expected to fail/skip outside a real DB — unrelated to these
127+
changes. `go build ./...` at the repo root also fails on unrelated
128+
pre-existing issues in this sandbox (no `libgit2` for `gitextractor`;
129+
`plugins/org` and other plugin packages are `main` packages meant to be
130+
built with `-buildmode=plugin`, not as ordinary binaries) — neither is a
131+
regression from these branches.
132+
- `gofmt -l` on all touched files, before and after fixes, on both
133+
branches.
134+
- Generating `backend/mocks/{core,helpers}` via `mockery` was required to
135+
run `server/services` tests locally (`make mock`, or the two `mockery
136+
--recursive ...` commands in `backend/Makefile`); that directory is
137+
gitignored and not checked in.

backend/plugins/jira/impl/impl.go

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,6 @@ import (
2525
"github.com/apache/incubator-devlake/core/dal"
2626
"github.com/apache/incubator-devlake/core/errors"
2727
coreModels "github.com/apache/incubator-devlake/core/models"
28-
"github.com/apache/incubator-devlake/core/models/domainlayer/crossdomain"
29-
"github.com/apache/incubator-devlake/core/models/domainlayer/didgen"
3028
"github.com/apache/incubator-devlake/core/plugin"
3129
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
3230
"github.com/apache/incubator-devlake/plugins/jira/api"
@@ -257,19 +255,10 @@ func (p Jira) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]int
257255
ApiClient: jiraApiClient,
258256
JiraServerInfo: *info,
259257
Board: scope,
260-
}
261-
262-
// Resolve the Devlake project name from the project_mapping table so it can
263-
// be used as a template variable in ExtraJQL
264-
if scope != nil {
265-
rowId := didgen.NewDomainIdGenerator(&models.JiraBoard{}).Generate(scope.ConnectionId, scope.BoardId)
266-
var mapping crossdomain.ProjectMapping
267-
err = db.First(&mapping, dal.Where("`table` = ? AND `row_id` = ?", "boards", rowId))
268-
if err == nil {
269-
taskData.ProjectName = mapping.ProjectName
270-
} else if !db.IsErrorNotFound(err) {
271-
logger.Error(err, "failed to load project mapping for board %s", rowId)
272-
}
258+
// op.ProjectName is injected by services.GeneratePlanJsonV200 at
259+
// blueprint-plan-generation time, when the running Devlake project is
260+
// known unambiguously.
261+
ProjectName: op.ProjectName,
273262
}
274263

275264
return taskData, nil

backend/plugins/jira/tasks/issue_collector.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,8 @@ func CollectIssues(taskCtx plugin.SubTaskContext) errors.Error {
112112
// JqlTemplateData holds the variables available inside an ExtraJQL template.
113113
// Users reference these with Go template syntax, e.g. `{{.BoardName}}`.
114114
type JqlTemplateData struct {
115-
BoardId uint64 // numeric ID of the connected Jira board
116-
BoardName string // display name of the connected Jira board
115+
BoardId uint64 // numeric ID of the connected Jira board
116+
BoardName string // display name of the connected Jira board
117117
ProjectName string // Devlake project name associated with the Jira board scope
118118
}
119119

@@ -134,8 +134,8 @@ func renderExtraJQL(tmplStr string, data *JiraTaskData) (string, errors.Error) {
134134
}
135135

136136
vars := JqlTemplateData{
137-
BoardId: data.Options.BoardId,
138-
ProjectName: data.ProjectName,
137+
BoardId: data.Options.BoardId,
138+
ProjectName: data.ProjectName,
139139
}
140140
if data.Board != nil {
141141
vars.BoardName = data.Board.Name

backend/plugins/jira/tasks/issue_collector_test.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,11 @@ func Test_buildFilterJQL(t *testing.T) {
143143
}
144144

145145
func Test_renderExtraJQL(t *testing.T) {
146-
makeData := func(boardId uint64, boardName string, _ string) *JiraTaskData {
146+
makeData := func(boardId uint64, boardName string, projectName string) *JiraTaskData {
147147
return &JiraTaskData{
148-
Options: &JiraOptions{BoardId: boardId},
149-
Board: &models.JiraBoard{BoardId: boardId, Name: boardName},
148+
Options: &JiraOptions{BoardId: boardId},
149+
Board: &models.JiraBoard{BoardId: boardId, Name: boardName},
150+
ProjectName: projectName,
150151
}
151152
}
152153

@@ -181,6 +182,18 @@ func Test_renderExtraJQL(t *testing.T) {
181182
data: &JiraTaskData{Options: &JiraOptions{BoardId: 1}, Board: nil},
182183
want: `project = ""`,
183184
},
185+
{
186+
name: "ProjectName substitution",
187+
tmpl: `labels = "{{.ProjectName}}"`,
188+
data: makeData(1, "My Board", "My Devlake Project"),
189+
want: `labels = "My Devlake Project"`,
190+
},
191+
{
192+
name: "board with no mapped Devlake project falls back to empty ProjectName",
193+
tmpl: `labels = "{{.ProjectName}}"`,
194+
data: makeData(1, "My Board", ""),
195+
want: `labels = ""`,
196+
},
184197
{
185198
name: "invalid template returns error",
186199
tmpl: `project = "{{.Unclosed"`,

backend/plugins/jira/tasks/task_data.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ type JiraOptions struct {
3131
ScopeConfig *models.JiraScopeConfig `json:"scopeConfig" mapstructure:"scopeConfig"`
3232
ScopeConfigId uint64 `json:"scopeConfigId" mapstructure:"scopeConfigId"`
3333
PageSize int `json:"pageSize" mapstructure:"pageSize"`
34+
// ProjectName is the Devlake project this pipeline run belongs to. It is
35+
// injected generically into every plugin's task options by
36+
// services.GeneratePlanJsonV200, not set by Jira's own blueprint plan
37+
// builder, since that's the only place in the framework where the
38+
// running project is known unambiguously (a board scope can be attached
39+
// to more than one Devlake project).
40+
ProjectName string `json:"projectName" mapstructure:"projectName,omitempty"`
3441
}
3542

3643
type JiraTaskData struct {

backend/server/services/blueprint_makeplan_v200.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,23 @@ func GeneratePlanJsonV200(
7070
}
7171
}
7272

73+
// let every task know which Devlake project it's running for, so plugins
74+
// can use it (e.g. Jira's ExtraJQL {{.ProjectName}}) without needing
75+
// project context threaded through their own blueprint-plan-building
76+
// logic. Unrecognized map keys are ignored by mapstructure decoding, so
77+
// this is safe for plugins that don't care about it.
78+
if projectName != "" {
79+
for _, plan := range sourcePlans {
80+
for _, stage := range plan {
81+
for _, task := range stage {
82+
if task.Options != nil {
83+
task.Options["projectName"] = projectName
84+
}
85+
}
86+
}
87+
}
88+
}
89+
7390
// skip collectors
7491
if skipCollectors {
7592
for i, plan := range sourcePlans {

backend/server/services/blueprint_makeplan_v200_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
mockplugin "github.com/apache/incubator-devlake/mocks/core/plugin"
3030
"github.com/apache/incubator-devlake/plugins/org/tasks"
3131
"github.com/stretchr/testify/assert"
32+
"github.com/stretchr/testify/mock"
3233
)
3334

3435
func TestMakePlanV200(t *testing.T) {
@@ -101,3 +102,45 @@ func TestMakePlanV200(t *testing.T) {
101102

102103
assert.Equal(t, expectedPlan, plan)
103104
}
105+
106+
// TestMakePlanV200InjectsProjectNameIntoDataSourceTaskOptions verifies that
107+
// GeneratePlanJsonV200 stamps the running Devlake project's name into every
108+
// data-source task's options, without requiring the plugin's own
109+
// MakeDataSourcePipelinePlanV200 implementation to know about it. This is
110+
// what lets a scope shared by multiple projects (e.g. a Jira board added to
111+
// two different Devlake projects) resolve the correct project per pipeline
112+
// run instead of guessing from a many-to-one reverse lookup.
113+
func TestMakePlanV200InjectsProjectNameIntoDataSourceTaskOptions(t *testing.T) {
114+
const projectName = "TestMakePlanV200InjectsProjectName-project"
115+
jiraName := "TestMakePlanV200InjectsProjectName-jira"
116+
connId := uint64(1)
117+
scopes := []*coreModels.BlueprintScope{{ScopeId: "jira:JiraBoard:1:1"}}
118+
outputPlan := coreModels.PipelinePlan{
119+
{
120+
{Plugin: jiraName, Options: map[string]interface{}{"boardId": float64(1)}},
121+
},
122+
}
123+
jira := new(mockplugin.CompositeDataSourcePluginBlueprintV200)
124+
jira.On("MakeDataSourcePipelinePlanV200", connId, scopes).Return(outputPlan, []plugin.Scope(nil), nil)
125+
plugin.RegisterPlugin(jiraName, jira)
126+
127+
// GeneratePlanJsonV200 also calls the "org" plugin's ProjectMapper
128+
// whenever projectName != "". Re-register it here (overwriting any
129+
// registration from other tests in this package) with permissive
130+
// matchers, since this test doesn't care about project_mapping.
131+
org := new(mockplugin.CompositeProjectMapper)
132+
org.On("MapProject", mock.Anything, mock.Anything).Return(coreModels.PipelinePlan{}, nil)
133+
plugin.RegisterPlugin("org", org)
134+
135+
connections := []*coreModels.BlueprintConnection{
136+
{PluginName: jiraName, ConnectionId: connId, Scopes: scopes},
137+
}
138+
139+
plan, err := GeneratePlanJsonV200(projectName, connections, nil, false)
140+
assert.Nil(t, err)
141+
assert.Equal(t, 1, len(plan))
142+
assert.Equal(t, 1, len(plan[0]))
143+
assert.Equal(t, projectName, plan[0][0].Options["projectName"])
144+
// the plugin's own option is preserved alongside the injected one
145+
assert.Equal(t, float64(1), plan[0][0].Options["boardId"])
146+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ const renderCollapseItems = ({
269269
label={
270270
<>
271271
<span>Extra JQL</span>
272-
<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)." />
272+
<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 this board belongs to)." />
273273
</>
274274
}
275275
extra='Tip: use Go template variables to make this dynamic, e.g. owner = "{{.BoardName}}"'

0 commit comments

Comments
 (0)