Skip to content

Commit 31ae01d

Browse files
authored
Fixes #22967: move auto-assigned incidents to the Assigned stage (#30594)
* Fixes #22967: move auto-assigned incidents to the Assigned stage An incident created on test failure already inherits its assignees from the test case owners, but the resolution workflow always entered NewStage and nothing ever advanced it. The incident stayed in New, and because the TCRS mirror only carries assignee details on Assigned records, the Incident Manager rendered it as unassigned even though the task had assignees. Drive the workflow's own "assign" transition right after the incident task is created when assignees resolved, so the incident lands on the Assigned stage with the owner attached and the TCRS record carries the assignee. Incidents without owners are untouched and stay in New. Stage and transition ids of TestCaseResolutionTaskWorkflow move into IncidentWorkflowStages so the TCRS sync handler and the new assignment path cannot drift apart. * Address review: warn on skipped auto-assign, assert typed assignee Log a warning when an incident has assignees but is not at the New stage, so a broken assumption about synchronous workflow start surfaces instead of silently leaving the incident unassigned. Assert the typed assignee id on the Assigned TCRS record rather than substring-matching the serialized details blob, which could pass on an incidental match elsewhere in the payload.
1 parent 4eb0518 commit 31ae01d

4 files changed

Lines changed: 362 additions & 4 deletions

File tree

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
/*
2+
* Copyright 2026 Collate.
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
* Unless required by applicable law or agreed to in writing, software
8+
* distributed under the License is distributed on an "AS IS" BASIS,
9+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
* See the License for the specific language governing permissions and
11+
* limitations under the License.
12+
*/
13+
14+
package org.openmetadata.it.tests;
15+
16+
import static org.awaitility.Awaitility.await;
17+
import static org.junit.jupiter.api.Assertions.assertEquals;
18+
import static org.junit.jupiter.api.Assertions.assertNotNull;
19+
import static org.junit.jupiter.api.Assertions.assertNull;
20+
import static org.junit.jupiter.api.Assertions.assertTrue;
21+
22+
import com.fasterxml.jackson.core.type.TypeReference;
23+
import java.time.Duration;
24+
import java.util.List;
25+
import java.util.Map;
26+
import java.util.UUID;
27+
import java.util.concurrent.atomic.AtomicReference;
28+
import org.junit.jupiter.api.Test;
29+
import org.junit.jupiter.api.extension.ExtendWith;
30+
import org.junit.jupiter.api.parallel.Execution;
31+
import org.junit.jupiter.api.parallel.ExecutionMode;
32+
import org.openmetadata.it.factories.DatabaseSchemaTestFactory;
33+
import org.openmetadata.it.factories.DatabaseServiceTestFactory;
34+
import org.openmetadata.it.factories.TableTestFactory;
35+
import org.openmetadata.it.factories.UserTestFactory;
36+
import org.openmetadata.it.util.SdkClients;
37+
import org.openmetadata.it.util.TestNamespace;
38+
import org.openmetadata.it.util.TestNamespaceExtension;
39+
import org.openmetadata.schema.api.tests.CreateTestCaseResult;
40+
import org.openmetadata.schema.entity.data.DatabaseSchema;
41+
import org.openmetadata.schema.entity.data.Table;
42+
import org.openmetadata.schema.entity.services.DatabaseService;
43+
import org.openmetadata.schema.entity.tasks.Task;
44+
import org.openmetadata.schema.entity.teams.User;
45+
import org.openmetadata.schema.tests.TestCase;
46+
import org.openmetadata.schema.tests.type.Assigned;
47+
import org.openmetadata.schema.tests.type.TestCaseResolutionStatus;
48+
import org.openmetadata.schema.tests.type.TestCaseResolutionStatusTypes;
49+
import org.openmetadata.schema.tests.type.TestCaseStatus;
50+
import org.openmetadata.schema.type.TaskEntityStatus;
51+
import org.openmetadata.schema.utils.JsonUtils;
52+
import org.openmetadata.sdk.client.OpenMetadataClient;
53+
import org.openmetadata.sdk.fluent.builders.TestCaseBuilder;
54+
import org.openmetadata.sdk.models.ListParams;
55+
import org.openmetadata.sdk.models.ListResponse;
56+
import org.openmetadata.sdk.network.HttpMethod;
57+
import org.openmetadata.sdk.network.RequestOptions;
58+
59+
/**
60+
* E2E tests for automatic incident assignment (issue #22967).
61+
*
62+
* <p>When a test case fails and the incident inherits assignees from the target entity's owners,
63+
* the incident must land on the workflow's {@code assigned} stage rather than sitting in {@code
64+
* new} with an assignee no consumer of the TCRS time series can see.
65+
*/
66+
@Execution(ExecutionMode.SAME_THREAD)
67+
@ExtendWith(TestNamespaceExtension.class)
68+
public class AutoAssignIncidentIT {
69+
70+
private static final String WORKFLOW_NAME = "TestCaseResolutionTaskWorkflow";
71+
private static final String NEW_STAGE_ID = "new";
72+
private static final String ASSIGNED_STAGE_ID = "assigned";
73+
private static final Duration PIPELINE_TIMEOUT = Duration.ofSeconds(120);
74+
75+
@Test
76+
void ownedTestCase_failedResult_incidentAutoAssigned(TestNamespace ns) {
77+
OpenMetadataClient client = SdkClients.adminClient();
78+
String id = ns.shortPrefix();
79+
80+
User owner = UserTestFactory.createUser(ns, "own" + id);
81+
TestCase testCase = createFailingTestCaseOwnedBy(client, ns, id, owner);
82+
83+
Task task = awaitIncidentTask(client, testCase);
84+
assertEquals(
85+
ASSIGNED_STAGE_ID,
86+
task.getWorkflowStageId(),
87+
"Incident with inherited owners should advance past the New stage");
88+
assertEquals(TaskEntityStatus.InProgress, task.getStatus());
89+
assertTrue(
90+
task.getAssignees().stream().anyMatch(a -> owner.getId().equals(a.getId())),
91+
"Owner should be an assignee of the incident task");
92+
93+
TestCase failedTc =
94+
client.testCases().getByName(testCase.getFullyQualifiedName(), "incidentId");
95+
UUID stateId = failedTc.getIncidentId();
96+
assertNotNull(stateId);
97+
98+
await()
99+
.atMost(PIPELINE_TIMEOUT)
100+
.pollInterval(Duration.ofSeconds(2))
101+
.until(
102+
() -> findTcrsOfType(client, stateId, TestCaseResolutionStatusTypes.Assigned) != null);
103+
104+
TestCaseResolutionStatus assigned =
105+
findTcrsOfType(client, stateId, TestCaseResolutionStatusTypes.Assigned);
106+
Assigned details =
107+
JsonUtils.convertValue(assigned.getTestCaseResolutionStatusDetails(), Assigned.class);
108+
assertNotNull(details.getAssignee(), "Assigned TCRS record must carry an assignee");
109+
assertEquals(
110+
owner.getId(),
111+
details.getAssignee().getId(),
112+
"Assigned TCRS record should name the owner as assignee");
113+
}
114+
115+
@Test
116+
void unownedTestCase_failedResult_incidentStaysNew(TestNamespace ns) {
117+
OpenMetadataClient client = SdkClients.adminClient();
118+
String id = ns.shortPrefix();
119+
120+
TestCase testCase = createFailingTestCaseOwnedBy(client, ns, id, null);
121+
122+
Task task = awaitIncidentTask(client, testCase);
123+
assertEquals(
124+
NEW_STAGE_ID,
125+
task.getWorkflowStageId(),
126+
"Incident without owners has nobody to assign to and must stay New");
127+
assertEquals(TaskEntityStatus.Open, task.getStatus());
128+
129+
TestCase failedTc =
130+
client.testCases().getByName(testCase.getFullyQualifiedName(), "incidentId");
131+
UUID stateId = failedTc.getIncidentId();
132+
assertNotNull(stateId);
133+
134+
await()
135+
.atMost(PIPELINE_TIMEOUT)
136+
.pollInterval(Duration.ofSeconds(2))
137+
.until(() -> findTcrsOfType(client, stateId, TestCaseResolutionStatusTypes.New) != null);
138+
assertNull(
139+
findTcrsOfType(client, stateId, TestCaseResolutionStatusTypes.Assigned),
140+
"An unowned incident must not produce an Assigned record");
141+
}
142+
143+
private TestCase createFailingTestCaseOwnedBy(
144+
OpenMetadataClient client, TestNamespace ns, String id, User owner) {
145+
DatabaseService service = DatabaseServiceTestFactory.createPostgresWithName("sv" + id, ns);
146+
DatabaseSchema schema = DatabaseSchemaTestFactory.createSimpleWithName("sc" + id, ns, service);
147+
Table table =
148+
TableTestFactory.createSimpleWithName("tbl" + id, ns, schema.getFullyQualifiedName());
149+
150+
if (owner != null) {
151+
setTableOwner(client, table, owner);
152+
}
153+
154+
TestCase testCase =
155+
TestCaseBuilder.create(client)
156+
.name("tc" + id)
157+
.forTable(table)
158+
.testDefinition("tableRowCountToEqual")
159+
.parameter("value", "100")
160+
.create();
161+
162+
awaitWorkflowDeployed(client);
163+
createFailedTestResult(client, testCase);
164+
return testCase;
165+
}
166+
167+
private void setTableOwner(OpenMetadataClient client, Table table, User owner) {
168+
String patchJson =
169+
String.format(
170+
"[{\"op\": \"add\", \"path\": \"/owners\", \"value\": "
171+
+ "[{\"id\": \"%s\", \"type\": \"user\"}]}]",
172+
owner.getId());
173+
client
174+
.getHttpClient()
175+
.executeForString(
176+
HttpMethod.PATCH,
177+
"/v1/tables/" + table.getId(),
178+
patchJson,
179+
RequestOptions.builder().header("Content-Type", "application/json-patch+json").build());
180+
}
181+
182+
private void awaitWorkflowDeployed(OpenMetadataClient client) {
183+
await()
184+
.atMost(Duration.ofSeconds(30))
185+
.pollInterval(Duration.ofSeconds(2))
186+
.until(
187+
() -> {
188+
try {
189+
var wd = client.workflowDefinitions().getByName(WORKFLOW_NAME, "deployed");
190+
return Boolean.TRUE.equals(wd.getDeployed());
191+
} catch (Exception e) {
192+
return false;
193+
}
194+
});
195+
}
196+
197+
private void createFailedTestResult(OpenMetadataClient client, TestCase testCase) {
198+
CreateTestCaseResult result = new CreateTestCaseResult();
199+
result.setTimestamp(System.currentTimeMillis());
200+
result.setTestCaseStatus(TestCaseStatus.Failed);
201+
result.setResult("Test failed");
202+
client.testCaseResults().create(testCase.getFullyQualifiedName(), result);
203+
}
204+
205+
private Task awaitIncidentTask(OpenMetadataClient client, TestCase testCase) {
206+
AtomicReference<Task> taskRef = new AtomicReference<>();
207+
await()
208+
.atMost(PIPELINE_TIMEOUT)
209+
.pollInterval(Duration.ofSeconds(2))
210+
.until(
211+
() -> {
212+
Task found = findIncidentTaskForTestCase(client, testCase);
213+
boolean started = found != null && found.getWorkflowInstanceId() != null;
214+
if (started) {
215+
taskRef.set(found);
216+
}
217+
return started;
218+
});
219+
return taskRef.get();
220+
}
221+
222+
private Task findIncidentTaskForTestCase(OpenMetadataClient client, TestCase testCase) {
223+
ListParams params =
224+
new ListParams()
225+
.addFilter("category", "Incident")
226+
.setFields("assignees,payload,about")
227+
.setLimit(100);
228+
ListResponse<Task> tasks = client.tasks().list(params);
229+
230+
return tasks.getData().stream()
231+
.filter(
232+
task ->
233+
task.getAbout() != null
234+
&& testCase
235+
.getFullyQualifiedName()
236+
.equals(task.getAbout().getFullyQualifiedName()))
237+
.findFirst()
238+
.orElse(null);
239+
}
240+
241+
private TestCaseResolutionStatus findTcrsOfType(
242+
OpenMetadataClient client, UUID stateId, TestCaseResolutionStatusTypes type) {
243+
return listTcrsForStateId(client, stateId).stream()
244+
.filter(r -> r.getTestCaseResolutionStatusType() == type)
245+
.findFirst()
246+
.orElse(null);
247+
}
248+
249+
@SuppressWarnings("unchecked")
250+
private List<TestCaseResolutionStatus> listTcrsForStateId(
251+
OpenMetadataClient client, UUID stateId) {
252+
try {
253+
String response =
254+
client
255+
.getHttpClient()
256+
.executeForString(
257+
HttpMethod.GET,
258+
"/v1/dataQuality/testCases/testCaseIncidentStatus/stateId/" + stateId,
259+
null,
260+
RequestOptions.builder().build());
261+
262+
Map<String, Object> result = JsonUtils.readValue(response, new TypeReference<>() {});
263+
List<Object> data = (List<Object>) result.get("data");
264+
if (data == null) {
265+
return List.of();
266+
}
267+
return data.stream()
268+
.map(d -> JsonUtils.convertValue(d, TestCaseResolutionStatus.class))
269+
.toList();
270+
} catch (Exception e) {
271+
return List.of();
272+
}
273+
}
274+
}

openmetadata-service/src/main/java/org/openmetadata/service/events/lifecycle/handlers/IncidentTcrsSyncHandler.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import org.openmetadata.service.exception.EntityNotFoundException;
3737
import org.openmetadata.service.jdbi3.TestCaseRepository;
3838
import org.openmetadata.service.jdbi3.TestCaseResolutionStatusRepository;
39+
import org.openmetadata.service.tasks.IncidentWorkflowStages;
3940

4041
/**
4142
* Mirrors task-first incident lifecycle events into the legacy {@code
@@ -80,10 +81,10 @@ public final class IncidentTcrsSyncHandler {
8081

8182
private static final Map<String, TestCaseResolutionStatusTypes> STAGE_TO_TCRS_STATUS =
8283
Map.of(
83-
"new", TestCaseResolutionStatusTypes.New,
84-
"ack", TestCaseResolutionStatusTypes.Ack,
85-
"assigned", TestCaseResolutionStatusTypes.Assigned,
86-
"resolved", TestCaseResolutionStatusTypes.Resolved);
84+
IncidentWorkflowStages.NEW_STAGE_ID, TestCaseResolutionStatusTypes.New,
85+
IncidentWorkflowStages.ACK_STAGE_ID, TestCaseResolutionStatusTypes.Ack,
86+
IncidentWorkflowStages.ASSIGNED_STAGE_ID, TestCaseResolutionStatusTypes.Assigned,
87+
IncidentWorkflowStages.RESOLVED_STAGE_ID, TestCaseResolutionStatusTypes.Resolved);
8788

8889
private static final String TEST_CASE_TYPE = "testCase";
8990

openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResolutionStatusRepository.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
import org.openmetadata.service.resources.dqtests.TestCaseResolutionStatusResource;
4545
import org.openmetadata.service.resources.feeds.MessageParser;
4646
import org.openmetadata.service.search.SearchListFilter;
47+
import org.openmetadata.service.tasks.IncidentWorkflowStages;
48+
import org.openmetadata.service.tasks.TaskWorkflowLifecycleResolver;
4749
import org.openmetadata.service.util.EntityUtil;
4850
import org.openmetadata.service.util.RestUtil;
4951
import org.openmetadata.service.util.incidentSeverityClassifier.IncidentSeverityClassifierInterface;
@@ -568,9 +570,59 @@ private static UUID createIncidentTask(TestCase testCase, String updatedBy) {
568570
"Incident task created on test failure: id={}, testCase={}",
569571
task.getId(),
570572
fullTestCase.getFullyQualifiedName());
573+
advanceAutoAssignedIncident(taskRepository, task.getId(), assignees, updatedBy);
571574
return task.getId();
572575
}
573576

577+
/**
578+
* Moves an incident that was auto-assigned from the test case owners out of the workflow's {@code
579+
* new} stage and into {@code assigned}, by driving the same {@code assign} transition a manual
580+
* assignment uses.
581+
*
582+
* <p>Without this the incident stays in {@code New}, and because the TCRS mirror only carries
583+
* assignee details on {@code Assigned} records, the Incident Manager renders it as unassigned even
584+
* though the task itself has assignees.
585+
*/
586+
private static void advanceAutoAssignedIncident(
587+
TaskRepository taskRepository,
588+
UUID taskId,
589+
List<EntityReference> assignees,
590+
String updatedBy) {
591+
if (!nullOrEmpty(assignees)) {
592+
try {
593+
Task current = taskRepository.get(null, taskId, taskRepository.getFields("*"));
594+
if (canAdvanceToAssignedStage(current)) {
595+
taskRepository.resolveTaskWithWorkflow(
596+
current,
597+
IncidentWorkflowStages.ASSIGN_TRANSITION_ID,
598+
null,
599+
null,
600+
null,
601+
null,
602+
updatedBy);
603+
LOG.info("Incident task {} auto-advanced to the assigned stage", taskId);
604+
} else {
605+
LOG.warn(
606+
"Incident task {} has assignees but sits at stage '{}' instead of '{}'; it stays New and renders as unassigned",
607+
taskId,
608+
current.getWorkflowStageId(),
609+
IncidentWorkflowStages.NEW_STAGE_ID);
610+
}
611+
} catch (Exception e) {
612+
// Best effort: an incident that fails to advance is still a usable incident sitting in
613+
// New, so never fail test result ingestion over it.
614+
LOG.warn("Failed to auto-advance incident task {} to the assigned stage", taskId, e);
615+
}
616+
}
617+
}
618+
619+
private static boolean canAdvanceToAssignedStage(Task task) {
620+
return IncidentWorkflowStages.NEW_STAGE_ID.equals(task.getWorkflowStageId())
621+
&& TaskWorkflowLifecycleResolver.findTransition(
622+
task, IncidentWorkflowStages.ASSIGN_TRANSITION_ID)
623+
!= null;
624+
}
625+
574626
private void setResolutionMetrics(
575627
TestCaseResolutionStatus lastIncident, TestCaseResolutionStatus newIncident) {
576628
List<Metric> metrics = new ArrayList<>();
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright 2026 Collate
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
15+
package org.openmetadata.service.tasks;
16+
17+
/**
18+
* Stage and transition identifiers declared by the {@code TestCaseResolutionTaskWorkflow} seed
19+
* definition. Kept in one place so the code paths that mirror or drive that workflow —
20+
* {@code IncidentTcrsSyncHandler} and the incident auto-assignment path — cannot drift apart.
21+
*/
22+
public final class IncidentWorkflowStages {
23+
public static final String NEW_STAGE_ID = "new";
24+
public static final String ACK_STAGE_ID = "ack";
25+
public static final String ASSIGNED_STAGE_ID = "assigned";
26+
public static final String RESOLVED_STAGE_ID = "resolved";
27+
28+
public static final String ASSIGN_TRANSITION_ID = "assign";
29+
30+
private IncidentWorkflowStages() {}
31+
}

0 commit comments

Comments
 (0)