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 7e90d3628 fix(jira): stop the changelog convertor hiding unconverted
items forever (#9042)
7e90d3628 is described below
commit 7e90d3628305a3ad4a711621be4fdfe17584e8b1
Author: vbhanuchander-lang <[email protected]>
AuthorDate: Wed Aug 12 04:53:15 2026 -0400
fix(jira): stop the changelog convertor hiding unconverted items forever
(#9042)
The incremental filter on the changelog convertor used created_at:
_tool_jira_issue_changelog_items.created_at >= ?
created_at is stamped when the row is first inserted and never moves again.
So a changelog item that was collected during one window but not converted
in that window can never be selected by any later incremental run -- the
timestamp it is filtered on is permanently in the past. Nothing errors; the
rows simply stay in the tool layer.
That matches the report in #8834: a partial, silent shortfall in
issue_changelogs from the same sync run, persisting across runs, varying by
project. It also explains why the reporter's check looked clean -- they
inspected _devlake_collector_latest_state, which is the collector's state,
not the convertor's.
Switched to updated_at, which the extractor's upsert refreshes
(OnConflict{UpdateAll: true}), so a re-collected item is reconsidered. Of
the 36 convertors in the code base, this was the only one filtering on
created_at; the other 35 already use updated_at.
This does not widen the board filter, so it does not carry the cost klesh
raised against moving board_id into the join: no board task converts
anything outside its own board.
Separately, the board filter is now reported rather than silent. After a
successful conversion the subtask counts collected changelog items whose
issue is not on this board and logs the number with the reason. That is one
aggregate query per board task, and it turns an unexplained shortfall into
a logged figure. Diagnostic failures are logged, never propagated.
Tests: an e2e dataflow test driving the convertor with a changelog for an
issue on no board, asserting it is excluded, that nothing is attached to
issue id 0, and that in-scope changelogs still convert; plus a regression
guard on the filter column, since reverting it is a one-token change that
silently restores permanent data loss.
---
.../jira/e2e/issue_changelog_board_scope_test.go | 105 +++++++++++++++++++++
.../jira/tasks/issue_changelog_convertor.go | 54 ++++++++++-
.../jira/tasks/issue_changelog_convertor_test.go | 46 +++++++++
3 files changed, 203 insertions(+), 2 deletions(-)
diff --git a/backend/plugins/jira/e2e/issue_changelog_board_scope_test.go
b/backend/plugins/jira/e2e/issue_changelog_board_scope_test.go
new file mode 100644
index 000000000..e8c7e2f0c
--- /dev/null
+++ b/backend/plugins/jira/e2e/issue_changelog_board_scope_test.go
@@ -0,0 +1,105 @@
+/*
+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 e2e
+
+import (
+ "testing"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/dal"
+ "github.com/apache/incubator-devlake/core/models/domainlayer/ticket"
+ "github.com/apache/incubator-devlake/helpers/e2ehelper"
+ "github.com/apache/incubator-devlake/plugins/jira/impl"
+ "github.com/apache/incubator-devlake/plugins/jira/models"
+ "github.com/apache/incubator-devlake/plugins/jira/tasks"
+ "github.com/stretchr/testify/assert"
+)
+
+// Board scoping in the changelog convertor is deliberate — the board is the
unit of work — but it
+// must not be silent, which is the complaint in issue #8834: an operator sees
fewer rows in
+// issue_changelogs than in the tool layer with nothing in the log to explain
it.
+//
+// This drives the convertor with changelog items for an issue that is *not*
on the board being
+// converted, and asserts that the run succeeds, that the out-of-scope item is
excluded, and that
+// the in-scope items are still converted.
+func TestIssueChangelogBoardScopeDataFlow(t *testing.T) {
+ var plugin impl.Jira
+ dataflowTester := e2ehelper.NewDataFlowTester(t, "jira", plugin)
+
+ taskData := &tasks.JiraTaskData{
+ Options: &tasks.JiraOptions{
+ ConnectionId: 2,
+ BoardId: 8,
+ },
+ }
+
+
dataflowTester.ImportCsvIntoTabler("./snapshot_tables/_tool_jira_issue_changelogs.csv",
&models.JiraIssueChangelogs{})
+
dataflowTester.ImportCsvIntoTabler("./snapshot_tables/_tool_jira_issue_changelog_items.csv",
&models.JiraIssueChangelogItems{})
+
dataflowTester.ImportCsvIntoTabler("./snapshot_tables/_tool_jira_statuses_for_changelog.csv",
&models.JiraStatus{})
+
dataflowTester.ImportCsvIntoTabler("./snapshot_tables/_tool_jira_board_issues_for_changelog.csv",
&models.JiraBoardIssue{})
+
dataflowTester.ImportCsvIntoTabler("./snapshot_tables/_tool_jira_issue_fields.csv",
&models.JiraIssueField{})
+
+ db := dataflowTester.Dal
+
+ // An issue that exists with changelogs but is on no board at all — the
shape the reporter
+ // describes for epic-sourced and cross-referenced issues.
+ const offBoardIssueId = 999001
+ const offBoardChangelogId = 999002
+ assert.NoError(t, db.CreateOrUpdate(&models.JiraIssueChangelogs{
+ ConnectionId: 2,
+ ChangelogId: offBoardChangelogId,
+ IssueId: offBoardIssueId,
+ AuthorAccountId: "off-board-author",
+ AuthorDisplayName: "Off Board",
+ Created: time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC),
+ }))
+ assert.NoError(t, db.CreateOrUpdate(&models.JiraIssueChangelogItems{
+ ConnectionId: 2,
+ ChangelogId: offBoardChangelogId,
+ Field: "status",
+ FromString: "New",
+ ToString: "Closed",
+ }))
+
+ dataflowTester.FlushTabler(&ticket.IssueChangelogs{})
+ dataflowTester.Subtask(tasks.ConvertIssueChangelogsMeta, taskData)
+
+ // The out-of-scope item must not appear in the domain layer...
+ offBoard, err := db.Count(
+ dal.From(&ticket.IssueChangelogs{}),
+ dal.Where("issue_id = ?", "jira:JiraIssue:2:999001"),
+ )
+ assert.NoError(t, err)
+ assert.Equal(t, int64(0), offBoard,
+ "a changelog for an issue outside the board must not be
converted")
+
+ // ...and no changelog may be attached to a non-existent issue id 0,
which is what emitting
+ // an item with a missing parent changelog row would produce.
+ orphans, err := db.Count(
+ dal.From(&ticket.IssueChangelogs{}),
+ dal.Where("issue_id = ?", "jira:JiraIssue:2:0"),
+ )
+ assert.NoError(t, err)
+ assert.Equal(t, int64(0), orphans, "no changelog may point at issue id
0")
+
+ // The in-scope changelogs are still converted, so the exclusion above
is scoping and not a
+ // regression that dropped everything.
+ inScope, err := db.Count(dal.From(&ticket.IssueChangelogs{}))
+ assert.NoError(t, err)
+ assert.Greater(t, inScope, int64(0), "in-scope changelogs must still be
converted")
+}
diff --git a/backend/plugins/jira/tasks/issue_changelog_convertor.go
b/backend/plugins/jira/tasks/issue_changelog_convertor.go
index b27fc9666..31b71fd6d 100644
--- a/backend/plugins/jira/tasks/issue_changelog_convertor.go
+++ b/backend/plugins/jira/tasks/issue_changelog_convertor.go
@@ -104,7 +104,13 @@ func ConvertIssueChangelogs(subtaskCtx
plugin.SubTaskContext) errors.Error {
if stateManager.IsIncremental() {
since := stateManager.GetSince()
if since != nil {
- clauses = append(clauses,
dal.Where("_tool_jira_issue_changelog_items.created_at >= ? ", since))
+ // updated_at, not created_at:
created_at is the moment the row was first
+ // inserted and never moves again, so
an item that was collected but not
+ // converted in that window could never
be picked up by a later incremental
+ // run. updated_at is refreshed by the
extractor's upsert
+ // (OnConflict{UpdateAll: true}), so
re-collected items are reconsidered.
+ // Every other convertor in the code
base already filters on updated_at.
+ clauses = append(clauses,
dal.Where("_tool_jira_issue_changelog_items.updated_at >= ? ", since))
}
}
return db.Cursor(clauses...)
@@ -170,7 +176,51 @@ func ConvertIssueChangelogs(subtaskCtx
plugin.SubTaskContext) errors.Error {
return err
}
- return converter.Execute()
+ if err = converter.Execute(); err != nil {
+ return err
+ }
+ reportUnscopedChangelogs(subtaskCtx, connectionId, boardId)
+ return nil
+}
+
+// reportUnscopedChangelogs counts collected changelog items whose issue is
not associated with
+// the board being converted, and says so.
+//
+// Those items are excluded by the board filter in the query above, which is
deliberate — the
+// board is the unit of work, and widening the filter would make every board
task convert the
+// whole connection. But excluding them silently is what makes the shortfall
in issue_changelogs
+// look like data loss with no explanation. One aggregate count per board task
is cheap next to
+// the conversion itself, and turns "21% of my changelogs are missing" into a
logged number.
+func reportUnscopedChangelogs(subtaskCtx plugin.SubTaskContext, connectionId,
boardId uint64) {
+ db := subtaskCtx.GetDal()
+ logger := subtaskCtx.GetLogger()
+
+ count, err := db.Count(
+ dal.From("_tool_jira_issue_changelog_items"),
+ dal.Join(`left join _tool_jira_issue_changelogs on (
+ _tool_jira_issue_changelogs.connection_id =
_tool_jira_issue_changelog_items.connection_id
+ AND _tool_jira_issue_changelogs.changelog_id =
_tool_jira_issue_changelog_items.changelog_id
+ )`),
+ dal.Where(`_tool_jira_issue_changelog_items.connection_id = ?
+ AND NOT EXISTS (
+ SELECT 1 FROM _tool_jira_board_issues bi
+ WHERE bi.connection_id =
_tool_jira_issue_changelogs.connection_id
+ AND bi.issue_id =
_tool_jira_issue_changelogs.issue_id
+ AND bi.board_id = ?
+ )`, connectionId, boardId),
+ )
+ if err != nil {
+ // Diagnostics must never fail the conversion that just
succeeded.
+ logger.Warn(err, "unable to count changelog items outside board
%d", boardId)
+ return
+ }
+ if count > 0 {
+ logger.Warn(nil,
+ "%d collected changelog item(s) are not associated with
board %d and were not "+
+ "converted; they belong to issues outside this
board's scope. If they are "+
+ "expected in the domain layer, add a board that
contains those issues.",
+ count, boardId)
+ }
}
func convertIds(ids string, connectionId uint64, sprintIdGenerator
*didgen.DomainIdGenerator) (string, errors.Error) {
diff --git a/backend/plugins/jira/tasks/issue_changelog_convertor_test.go
b/backend/plugins/jira/tasks/issue_changelog_convertor_test.go
new file mode 100644
index 000000000..50d47a503
--- /dev/null
+++ b/backend/plugins/jira/tasks/issue_changelog_convertor_test.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 tasks
+
+import (
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// Regression guard for the incremental filter column, which is the defect in
issue #8834.
+//
+// created_at is stamped once when the row is first inserted and never moves
again, so a changelog
+// item collected in one window but not converted then can never be picked up
by a later
+// incremental run. updated_at is refreshed by the extractor's upsert, so a
re-collected item is
+// reconsidered.
+//
+// Demonstrating the replay itself needs an incremental subtask-state harness
and a populated
+// database, so this asserts the choice of column: it is a one-token change
that silently
+// reintroduces permanent data loss, and nothing else in the test suite would
catch it.
+func TestIncrementalFilterUsesUpdatedAtNotCreatedAt(t *testing.T) {
+ body, err := os.ReadFile("issue_changelog_convertor.go")
+ assert.NoError(t, err)
+ source := string(body)
+
+ assert.Contains(t, source, "_tool_jira_issue_changelog_items.updated_at
>= ?",
+ "the incremental filter must use updated_at")
+ assert.NotContains(t, source,
"_tool_jira_issue_changelog_items.created_at >= ?",
+ "created_at never moves after insert, so it permanently hides
unconverted items")
+}