Skip to content

Commit 805ed98

Browse files
kesambaclaude
andcommitted
feat(dora): scope incidents and deployment links to the project's slice
ConvertIssuesToIncidents and ConnectIncidentToDeployment selected incidents by board-level project_mapping only, so every project sharing a board got every incident on it. Also filter on board_issues.project_name, falling back to the whole board when it has no per-project stamps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 59592b4 commit 805ed98

4 files changed

Lines changed: 466 additions & 26 deletions

File tree

‎backend/plugins/dora/tasks/incident_deploy_connector.go‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,37 @@ type simpleCicdDeploymentCommit struct {
4444
FinishedDate *time.Time
4545
}
4646

47+
// connectIncidentInScopeClauses selects the incidents that belong to a single
48+
// DevLake project's slice of a (possibly shared) Jira board.
49+
//
50+
// An incident is scoped only by scope_id (= board id), so joining it to
51+
// project_mapping alone matches EVERY project pointing at that board. When
52+
// several projects share a board, that leaks one project's incidents into
53+
// another's Change Failure Rate. We therefore additionally join board_issues
54+
// (which carries the per-project slice stamped at collection time via ExtraJQL)
55+
// and keep only the incidents whose underlying issue was collected for this
56+
// project. The empty/NULL board_issues.project_name fallback preserves
57+
// pre-feature / single-project data.
58+
func connectIncidentInScopeClauses(db dal.Dal, projectName string) []dal.Clause {
59+
return []dal.Clause{
60+
dal.From(`incidents i`),
61+
dal.Join("join project_mapping pm on pm.row_id = i.scope_id and " + quoteTableColumn(db, "pm") + " = " + quoteTableColumn(db, "i")),
62+
dal.Join(`left join board_issues bi on bi.issue_id = i.id and bi.board_id = i.scope_id`),
63+
dal.Where(
64+
`pm.project_name = ?
65+
and (
66+
bi.project_name = ?
67+
or not exists (
68+
select 1 from board_issues b2
69+
where b2.board_id = i.scope_id
70+
and b2.project_name is not null and b2.project_name <> ''
71+
)
72+
)`,
73+
projectName, projectName,
74+
),
75+
}
76+
}
77+
4778
// ConnectIncidentToDeployment will generate data to crossdomain.ProjectIncidentDeploymentRelationship.
4879
func ConnectIncidentToDeployment(taskCtx plugin.SubTaskContext) errors.Error {
4980
db := taskCtx.GetDal()
@@ -55,12 +86,8 @@ func ConnectIncidentToDeployment(taskCtx plugin.SubTaskContext) errors.Error {
5586
return errors.Default.Wrap(err, "error deleting previous project_incident_deployment_relationships")
5687
}
5788
logger.Info("delete previous project_incident_deployment_relationships")
58-
// select all issues belongs to the board
59-
clauses := []dal.Clause{
60-
dal.From(`incidents i`),
61-
dal.Join(`left join project_mapping pm on pm.row_id = i.scope_id and pm.table = i.table`),
62-
dal.Where("pm.project_name = ?", data.Options.ProjectName),
63-
}
89+
// select all incidents belonging to this project's slice of the board
90+
clauses := connectIncidentInScopeClauses(db, data.Options.ProjectName)
6491

6592
//count, err := db.Count(
6693
// dal.From(`incidents i`),
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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 tasks
19+
20+
import (
21+
"sort"
22+
"testing"
23+
24+
"github.com/apache/devlake/core/dal"
25+
"github.com/apache/devlake/core/models/domainlayer"
26+
"github.com/apache/devlake/core/models/domainlayer/crossdomain"
27+
"github.com/apache/devlake/core/models/domainlayer/ticket"
28+
"github.com/apache/devlake/impls/dalgorm"
29+
"github.com/stretchr/testify/require"
30+
"gorm.io/driver/sqlite"
31+
"gorm.io/gorm"
32+
)
33+
34+
// TestConnectIncidentInScopeProjectFilter reproduces the bug behind "the board
35+
// still contains incidents that relate to Jira issues that shouldn't be there".
36+
//
37+
// ConnectIncidentToDeployment builds project_incident_deployment_relationships
38+
// (the table the Change Failure Rate metric reads). It used to select incidents
39+
// by project_mapping on scope_id (= board) ONLY, so on a board shared by several
40+
// projects, every incident on the board was attached to every project -- the
41+
// incident objects leaked across projects even though the per-issue slice was
42+
// correct.
43+
//
44+
// Shared board (board1), mapped to both ProjectA and ProjectB. Incidents carry
45+
// scope_id = board1 (that is all an incident knows). The per-project slice lives
46+
// on board_issues.project_name:
47+
//
48+
// incident/issue 1001 board_issues.project_name = ProjectA
49+
// incident/issue 1002 board_issues.project_name = ProjectB
50+
// incident/issue 1003 board_issues.project_name = '' (legacy)
51+
//
52+
// Expected selection:
53+
// ProjectA -> {1001, 1003} (never 1002)
54+
// ProjectB -> {1002, 1003} (never 1001)
55+
//
56+
// On the OLD board-only query, ProjectA would also select 1002 -> the leak.
57+
func TestConnectIncidentInScopeProjectFilter(t *testing.T) {
58+
db := newDoraIncidentDB(t)
59+
60+
const board = "board1"
61+
seedIncident(t, db, "1001", board)
62+
seedIncident(t, db, "1002", board)
63+
seedIncident(t, db, "1003", board)
64+
65+
seedDoraBoardIssue(t, db, board, "1001", "ProjectA")
66+
seedDoraBoardIssue(t, db, board, "1002", "ProjectB")
67+
// 1003 is an unstamped (empty) row on a board that DOES have real per-project
68+
// stamps. On such a board the empty/NULL rows are the ambiguous "unfiltered
69+
// pool" and must NOT be attributed to any project (that was the leak).
70+
seedDoraBoardIssue(t, db, board, "1003", "")
71+
72+
seedDoraProjectMapping(t, db, "ProjectA", "boards", board)
73+
seedDoraProjectMapping(t, db, "ProjectB", "boards", board)
74+
75+
projectA := queryConnectIncidentIds(t, db, "ProjectA")
76+
projectB := queryConnectIncidentIds(t, db, "ProjectB")
77+
78+
require.Equal(t, []string{"1001"}, projectA)
79+
require.NotContains(t, projectA, "1002", "ProjectA must not attach ProjectB's incident on a shared board")
80+
require.NotContains(t, projectA, "1003", "unstamped rows on a stamped board must be excluded")
81+
82+
require.Equal(t, []string{"1002"}, projectB)
83+
require.NotContains(t, projectB, "1001")
84+
require.NotContains(t, projectB, "1003")
85+
}
86+
87+
// TestConnectIncidentInScopeExcludesNullPool reproduces the real staging state:
88+
// ProjectA was collected (its ExtraJQL slice stamped with "ProjectA"), while the
89+
// rest of the board's incidents are left over from an earlier unfiltered
90+
// collection and carry a NULL project_name. ProjectA's Change Failure Rate must
91+
// include ONLY its stamped slice -- the NULL "unfiltered pool" must be excluded,
92+
// not re-admitted.
93+
//
94+
// This is the case the previous fix got wrong: a NULL/empty fallback re-added
95+
// every NULL row to every project, so ProjectA showed the whole board.
96+
func TestConnectIncidentInScopeExcludesNullPool(t *testing.T) {
97+
db := newDoraIncidentDB(t)
98+
99+
const board = "board1"
100+
// ProjectA's real slice
101+
seedIncident(t, db, "A1", board)
102+
seedIncident(t, db, "A2", board)
103+
seedDoraBoardIssue(t, db, board, "A1", "ProjectA")
104+
seedDoraBoardIssue(t, db, board, "A2", "ProjectA")
105+
// leftover unfiltered pool from an earlier collection (NULL project_name)
106+
seedIncident(t, db, "N1", board)
107+
seedIncident(t, db, "N2", board)
108+
seedIncident(t, db, "N3", board)
109+
seedDoraBoardIssueNull(t, db, board, "N1")
110+
seedDoraBoardIssueNull(t, db, board, "N2")
111+
seedDoraBoardIssueNull(t, db, board, "N3")
112+
113+
seedDoraProjectMapping(t, db, "ProjectA", "boards", board)
114+
115+
ids := queryConnectIncidentIds(t, db, "ProjectA")
116+
require.Equal(t, []string{"A1", "A2"}, ids, "only ProjectA's stamped slice may count; the NULL pool must be excluded")
117+
require.NotContains(t, ids, "N1")
118+
require.NotContains(t, ids, "N2")
119+
require.NotContains(t, ids, "N3")
120+
}
121+
122+
// TestConnectIncidentInScopeNoShare is the control: a non-shared board with no
123+
// per-project slice still attaches all of its incidents (no regression).
124+
func TestConnectIncidentInScopeNoShare(t *testing.T) {
125+
db := newDoraIncidentDB(t)
126+
127+
const board = "boardSolo"
128+
seedIncident(t, db, "2001", board)
129+
seedIncident(t, db, "2002", board)
130+
seedDoraBoardIssue(t, db, board, "2001", "")
131+
seedDoraBoardIssue(t, db, board, "2002", "")
132+
seedDoraProjectMapping(t, db, "SoloProject", "boards", board)
133+
134+
ids := queryConnectIncidentIds(t, db, "SoloProject")
135+
require.Equal(t, []string{"2001", "2002"}, ids)
136+
}
137+
138+
// --- helpers ---------------------------------------------------------------
139+
140+
func newDoraIncidentDB(t *testing.T) dal.Dal {
141+
gormDb, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
142+
require.NoError(t, err)
143+
require.NoError(t, gormDb.AutoMigrate(&ticket.Incident{}, &ticket.BoardIssue{}, &crossdomain.ProjectMapping{}))
144+
require.NoError(t, gormDb.Exec("DELETE FROM incidents").Error)
145+
require.NoError(t, gormDb.Exec("DELETE FROM board_issues").Error)
146+
require.NoError(t, gormDb.Exec("DELETE FROM project_mapping").Error)
147+
return dalgorm.NewDalgorm(gormDb)
148+
}
149+
150+
func seedIncident(t *testing.T, db dal.Dal, id, boardId string) {
151+
require.NoError(t, db.Create(&ticket.Incident{
152+
DomainEntity: domainlayer.DomainEntity{Id: id},
153+
ScopeId: boardId,
154+
Table: "boards",
155+
}))
156+
}
157+
158+
func seedDoraBoardIssue(t *testing.T, db dal.Dal, boardId, issueId, projectName string) {
159+
require.NoError(t, db.Create(&ticket.BoardIssue{
160+
BoardId: boardId,
161+
IssueId: issueId,
162+
ProjectName: projectName,
163+
}))
164+
}
165+
166+
func seedDoraBoardIssueNull(t *testing.T, db dal.Dal, boardId, issueId string) {
167+
// Insert a row whose project_name is SQL NULL (the leftover unfiltered pool),
168+
// which is distinct from the empty-string case.
169+
require.NoError(t, db.Exec(
170+
"INSERT INTO board_issues (board_id, issue_id, project_name) VALUES (?, ?, NULL)",
171+
boardId, issueId,
172+
))
173+
}
174+
175+
func seedDoraProjectMapping(t *testing.T, db dal.Dal, projectName, table, rowId string) {
176+
require.NoError(t, db.Create(&crossdomain.ProjectMapping{
177+
ProjectName: projectName,
178+
Table: table,
179+
RowId: rowId,
180+
}))
181+
}
182+
183+
func queryConnectIncidentIds(t *testing.T, db dal.Dal, projectName string) []string {
184+
cursor, err := db.Cursor(connectIncidentInScopeClauses(db, projectName)...)
185+
require.NoError(t, err)
186+
defer cursor.Close()
187+
188+
var ids []string
189+
for cursor.Next() {
190+
row := &ticket.Incident{}
191+
require.NoError(t, db.Fetch(cursor, row))
192+
ids = append(ids, row.Id)
193+
}
194+
sort.Strings(ids)
195+
return ids
196+
}

‎backend/plugins/dora/tasks/incident_from_issue_generator.go‎

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -42,26 +42,77 @@ type issueWithBoardId struct {
4242
BoardId string
4343
}
4444

45+
// inScopeIncidentIssueClauses builds the query that selects the INCIDENT issues
46+
// belonging to a single DevLake project's slice of a (possibly shared) Jira
47+
// board.
48+
//
49+
// When several projects point at the same board, each project's Extra JQL
50+
// records its slice on board_issues.project_name at collection time. This query
51+
// therefore filters on BOTH the board-level project_mapping (pm.project_name)
52+
// AND the issue-level slice (bi.project_name), so a project only turns its own
53+
// issues into incidents. The empty/NULL bi.project_name fallback preserves
54+
// pre-feature (single-project) data that was collected before project_name
55+
// existed on board_issues.
56+
func inScopeIncidentIssueClauses(db dal.Dal, projectName string) []dal.Clause {
57+
return []dal.Clause{
58+
dal.Select("i.*, bi.board_id as board_id"),
59+
dal.From(`issues i`),
60+
dal.Join(`join board_issues bi on bi.issue_id = i.id`),
61+
dal.Join("join project_mapping pm on pm.row_id = bi.board_id and " + quoteTableColumn(db, "pm") + " = 'boards'"),
62+
dal.Where(
63+
`i.type = ? and pm.project_name = ?
64+
and (
65+
bi.project_name = ?
66+
or not exists (
67+
select 1 from board_issues b2
68+
where b2.board_id = bi.board_id
69+
and b2.project_name is not null and b2.project_name <> ''
70+
)
71+
)`,
72+
"INCIDENT", projectName, projectName,
73+
),
74+
}
75+
}
76+
77+
// quoteTableColumn returns the `table` column of the given alias quoted for the
78+
// current dialect. `table` is a reserved word: MySQL quotes identifiers with
79+
// backticks, while Postgres (and SQLite, used by the unit tests) use double
80+
// quotes.
81+
func quoteTableColumn(db dal.Dal, alias string) string {
82+
if db.Dialect() == "mysql" {
83+
return alias + ".`table`"
84+
}
85+
return alias + `."table"`
86+
}
87+
4588
func ConvertIssuesToIncidents(taskCtx plugin.SubTaskContext) errors.Error {
4689
db := taskCtx.GetDal()
4790
data := taskCtx.GetData().(*DoraTaskData)
4891
if data.DisableIssueToIncidentGenerator {
4992
return nil
5093
}
94+
pmTable := quoteTableColumn(db, "pm")
5195
// clear previous incidents and incident_assignees from the project
5296
deleteIncidentsSql := `
5397
DELETE
5498
FROM incidents
5599
WHERE id IN
56100
(SELECT i.id
57101
FROM issues i
58-
LEFT JOIN board_issues bi ON bi.issue_id = i.id
59-
LEFT JOIN project_mapping pm ON pm.row_id = bi.board_id
102+
JOIN board_issues bi ON bi.issue_id = i.id
103+
JOIN project_mapping pm ON pm.row_id = bi.board_id AND ` + pmTable + ` = 'boards'
60104
WHERE i.type = ?
61105
AND pm.project_name = ?
62-
AND pm.table = ?)
106+
AND (
107+
bi.project_name = ?
108+
OR NOT EXISTS (
109+
SELECT 1 FROM board_issues b2
110+
WHERE b2.board_id = bi.board_id
111+
AND b2.project_name IS NOT NULL AND b2.project_name <> ''
112+
)
113+
))
63114
`
64-
if err := db.Exec(deleteIncidentsSql, "INCIDENT", data.Options.ProjectName, "boards"); err != nil {
115+
if err := db.Exec(deleteIncidentsSql, "INCIDENT", data.Options.ProjectName, data.Options.ProjectName); err != nil {
65116
return errors.Default.Wrap(err, "error deleting previous incidents")
66117

67118
}
@@ -71,28 +122,25 @@ func ConvertIssuesToIncidents(taskCtx plugin.SubTaskContext) errors.Error {
71122
WHERE incident_id IN
72123
(SELECT i.id
73124
FROM issues i
74-
LEFT JOIN board_issues bi ON bi.issue_id = i.id
75-
LEFT JOIN project_mapping pm ON pm.row_id = bi.board_id
125+
JOIN board_issues bi ON bi.issue_id = i.id
126+
JOIN project_mapping pm ON pm.row_id = bi.board_id AND ` + pmTable + ` = 'boards'
76127
WHERE i.type = ?
77128
AND pm.project_name = ?
78-
AND pm.table = ?)
129+
AND (
130+
bi.project_name = ?
131+
OR NOT EXISTS (
132+
SELECT 1 FROM board_issues b2
133+
WHERE b2.board_id = bi.board_id
134+
AND b2.project_name IS NOT NULL AND b2.project_name <> ''
135+
)
136+
))
79137
`
80-
if err := db.Exec(deleteIncidentAssigneesSql, "INCIDENT", data.Options.ProjectName, "boards"); err != nil {
138+
if err := db.Exec(deleteIncidentAssigneesSql, "INCIDENT", data.Options.ProjectName, data.Options.ProjectName); err != nil {
81139
return errors.Default.Wrap(err, "error deleting previous incident_assignees")
82140
}
83141

84-
// select all issues belongs to the board
85-
clauses := []dal.Clause{
86-
dal.Select("i.*, bi.board_id as board_id"),
87-
dal.From(`issues i`),
88-
dal.Join(`left join board_issues bi on bi.issue_id = i.id`),
89-
dal.Join(`left join project_mapping pm on pm.row_id = bi.board_id`),
90-
dal.Where(
91-
"i.type = ? and pm.project_name = ? and pm.table = ?",
92-
"INCIDENT", data.Options.ProjectName, "boards",
93-
),
94-
}
95-
cursor, err := db.Cursor(clauses...)
142+
// select all issues belongs to the board, scoped to this project's slice
143+
cursor, err := db.Cursor(inScopeIncidentIssueClauses(db, data.Options.ProjectName)...)
96144
if err != nil {
97145
return err
98146
}

0 commit comments

Comments
 (0)