diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/AutoAssignIncidentIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/AutoAssignIncidentIT.java new file mode 100644 index 000000000000..ac1bbb6a21a7 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/AutoAssignIncidentIT.java @@ -0,0 +1,274 @@ +/* + * Copyright 2026 Collate. + * Licensed 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 org.openmetadata.it.tests; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.core.type.TypeReference; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.openmetadata.it.factories.DatabaseSchemaTestFactory; +import org.openmetadata.it.factories.DatabaseServiceTestFactory; +import org.openmetadata.it.factories.TableTestFactory; +import org.openmetadata.it.factories.UserTestFactory; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.it.util.TestNamespaceExtension; +import org.openmetadata.schema.api.tests.CreateTestCaseResult; +import org.openmetadata.schema.entity.data.DatabaseSchema; +import org.openmetadata.schema.entity.data.Table; +import org.openmetadata.schema.entity.services.DatabaseService; +import org.openmetadata.schema.entity.tasks.Task; +import org.openmetadata.schema.entity.teams.User; +import org.openmetadata.schema.tests.TestCase; +import org.openmetadata.schema.tests.type.Assigned; +import org.openmetadata.schema.tests.type.TestCaseResolutionStatus; +import org.openmetadata.schema.tests.type.TestCaseResolutionStatusTypes; +import org.openmetadata.schema.tests.type.TestCaseStatus; +import org.openmetadata.schema.type.TaskEntityStatus; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.sdk.client.OpenMetadataClient; +import org.openmetadata.sdk.fluent.builders.TestCaseBuilder; +import org.openmetadata.sdk.models.ListParams; +import org.openmetadata.sdk.models.ListResponse; +import org.openmetadata.sdk.network.HttpMethod; +import org.openmetadata.sdk.network.RequestOptions; + +/** + * E2E tests for automatic incident assignment (issue #22967). + * + *

When a test case fails and the incident inherits assignees from the target entity's owners, + * the incident must land on the workflow's {@code assigned} stage rather than sitting in {@code + * new} with an assignee no consumer of the TCRS time series can see. + */ +@Execution(ExecutionMode.SAME_THREAD) +@ExtendWith(TestNamespaceExtension.class) +public class AutoAssignIncidentIT { + + private static final String WORKFLOW_NAME = "TestCaseResolutionTaskWorkflow"; + private static final String NEW_STAGE_ID = "new"; + private static final String ASSIGNED_STAGE_ID = "assigned"; + private static final Duration PIPELINE_TIMEOUT = Duration.ofSeconds(120); + + @Test + void ownedTestCase_failedResult_incidentAutoAssigned(TestNamespace ns) { + OpenMetadataClient client = SdkClients.adminClient(); + String id = ns.shortPrefix(); + + User owner = UserTestFactory.createUser(ns, "own" + id); + TestCase testCase = createFailingTestCaseOwnedBy(client, ns, id, owner); + + Task task = awaitIncidentTask(client, testCase); + assertEquals( + ASSIGNED_STAGE_ID, + task.getWorkflowStageId(), + "Incident with inherited owners should advance past the New stage"); + assertEquals(TaskEntityStatus.InProgress, task.getStatus()); + assertTrue( + task.getAssignees().stream().anyMatch(a -> owner.getId().equals(a.getId())), + "Owner should be an assignee of the incident task"); + + TestCase failedTc = + client.testCases().getByName(testCase.getFullyQualifiedName(), "incidentId"); + UUID stateId = failedTc.getIncidentId(); + assertNotNull(stateId); + + await() + .atMost(PIPELINE_TIMEOUT) + .pollInterval(Duration.ofSeconds(2)) + .until( + () -> findTcrsOfType(client, stateId, TestCaseResolutionStatusTypes.Assigned) != null); + + TestCaseResolutionStatus assigned = + findTcrsOfType(client, stateId, TestCaseResolutionStatusTypes.Assigned); + Assigned details = + JsonUtils.convertValue(assigned.getTestCaseResolutionStatusDetails(), Assigned.class); + assertNotNull(details.getAssignee(), "Assigned TCRS record must carry an assignee"); + assertEquals( + owner.getId(), + details.getAssignee().getId(), + "Assigned TCRS record should name the owner as assignee"); + } + + @Test + void unownedTestCase_failedResult_incidentStaysNew(TestNamespace ns) { + OpenMetadataClient client = SdkClients.adminClient(); + String id = ns.shortPrefix(); + + TestCase testCase = createFailingTestCaseOwnedBy(client, ns, id, null); + + Task task = awaitIncidentTask(client, testCase); + assertEquals( + NEW_STAGE_ID, + task.getWorkflowStageId(), + "Incident without owners has nobody to assign to and must stay New"); + assertEquals(TaskEntityStatus.Open, task.getStatus()); + + TestCase failedTc = + client.testCases().getByName(testCase.getFullyQualifiedName(), "incidentId"); + UUID stateId = failedTc.getIncidentId(); + assertNotNull(stateId); + + await() + .atMost(PIPELINE_TIMEOUT) + .pollInterval(Duration.ofSeconds(2)) + .until(() -> findTcrsOfType(client, stateId, TestCaseResolutionStatusTypes.New) != null); + assertNull( + findTcrsOfType(client, stateId, TestCaseResolutionStatusTypes.Assigned), + "An unowned incident must not produce an Assigned record"); + } + + private TestCase createFailingTestCaseOwnedBy( + OpenMetadataClient client, TestNamespace ns, String id, User owner) { + DatabaseService service = DatabaseServiceTestFactory.createPostgresWithName("sv" + id, ns); + DatabaseSchema schema = DatabaseSchemaTestFactory.createSimpleWithName("sc" + id, ns, service); + Table table = + TableTestFactory.createSimpleWithName("tbl" + id, ns, schema.getFullyQualifiedName()); + + if (owner != null) { + setTableOwner(client, table, owner); + } + + TestCase testCase = + TestCaseBuilder.create(client) + .name("tc" + id) + .forTable(table) + .testDefinition("tableRowCountToEqual") + .parameter("value", "100") + .create(); + + awaitWorkflowDeployed(client); + createFailedTestResult(client, testCase); + return testCase; + } + + private void setTableOwner(OpenMetadataClient client, Table table, User owner) { + String patchJson = + String.format( + "[{\"op\": \"add\", \"path\": \"/owners\", \"value\": " + + "[{\"id\": \"%s\", \"type\": \"user\"}]}]", + owner.getId()); + client + .getHttpClient() + .executeForString( + HttpMethod.PATCH, + "/v1/tables/" + table.getId(), + patchJson, + RequestOptions.builder().header("Content-Type", "application/json-patch+json").build()); + } + + private void awaitWorkflowDeployed(OpenMetadataClient client) { + await() + .atMost(Duration.ofSeconds(30)) + .pollInterval(Duration.ofSeconds(2)) + .until( + () -> { + try { + var wd = client.workflowDefinitions().getByName(WORKFLOW_NAME, "deployed"); + return Boolean.TRUE.equals(wd.getDeployed()); + } catch (Exception e) { + return false; + } + }); + } + + private void createFailedTestResult(OpenMetadataClient client, TestCase testCase) { + CreateTestCaseResult result = new CreateTestCaseResult(); + result.setTimestamp(System.currentTimeMillis()); + result.setTestCaseStatus(TestCaseStatus.Failed); + result.setResult("Test failed"); + client.testCaseResults().create(testCase.getFullyQualifiedName(), result); + } + + private Task awaitIncidentTask(OpenMetadataClient client, TestCase testCase) { + AtomicReference taskRef = new AtomicReference<>(); + await() + .atMost(PIPELINE_TIMEOUT) + .pollInterval(Duration.ofSeconds(2)) + .until( + () -> { + Task found = findIncidentTaskForTestCase(client, testCase); + boolean started = found != null && found.getWorkflowInstanceId() != null; + if (started) { + taskRef.set(found); + } + return started; + }); + return taskRef.get(); + } + + private Task findIncidentTaskForTestCase(OpenMetadataClient client, TestCase testCase) { + ListParams params = + new ListParams() + .addFilter("category", "Incident") + .setFields("assignees,payload,about") + .setLimit(100); + ListResponse tasks = client.tasks().list(params); + + return tasks.getData().stream() + .filter( + task -> + task.getAbout() != null + && testCase + .getFullyQualifiedName() + .equals(task.getAbout().getFullyQualifiedName())) + .findFirst() + .orElse(null); + } + + private TestCaseResolutionStatus findTcrsOfType( + OpenMetadataClient client, UUID stateId, TestCaseResolutionStatusTypes type) { + return listTcrsForStateId(client, stateId).stream() + .filter(r -> r.getTestCaseResolutionStatusType() == type) + .findFirst() + .orElse(null); + } + + @SuppressWarnings("unchecked") + private List listTcrsForStateId( + OpenMetadataClient client, UUID stateId) { + try { + String response = + client + .getHttpClient() + .executeForString( + HttpMethod.GET, + "/v1/dataQuality/testCases/testCaseIncidentStatus/stateId/" + stateId, + null, + RequestOptions.builder().build()); + + Map result = JsonUtils.readValue(response, new TypeReference<>() {}); + List data = (List) result.get("data"); + if (data == null) { + return List.of(); + } + return data.stream() + .map(d -> JsonUtils.convertValue(d, TestCaseResolutionStatus.class)) + .toList(); + } catch (Exception e) { + return List.of(); + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/events/lifecycle/handlers/IncidentTcrsSyncHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/events/lifecycle/handlers/IncidentTcrsSyncHandler.java index d7d4f33d5975..f4f087c51f58 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/events/lifecycle/handlers/IncidentTcrsSyncHandler.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/events/lifecycle/handlers/IncidentTcrsSyncHandler.java @@ -36,6 +36,7 @@ import org.openmetadata.service.exception.EntityNotFoundException; import org.openmetadata.service.jdbi3.TestCaseRepository; import org.openmetadata.service.jdbi3.TestCaseResolutionStatusRepository; +import org.openmetadata.service.tasks.IncidentWorkflowStages; /** * Mirrors task-first incident lifecycle events into the legacy {@code @@ -80,10 +81,10 @@ public final class IncidentTcrsSyncHandler { private static final Map STAGE_TO_TCRS_STATUS = Map.of( - "new", TestCaseResolutionStatusTypes.New, - "ack", TestCaseResolutionStatusTypes.Ack, - "assigned", TestCaseResolutionStatusTypes.Assigned, - "resolved", TestCaseResolutionStatusTypes.Resolved); + IncidentWorkflowStages.NEW_STAGE_ID, TestCaseResolutionStatusTypes.New, + IncidentWorkflowStages.ACK_STAGE_ID, TestCaseResolutionStatusTypes.Ack, + IncidentWorkflowStages.ASSIGNED_STAGE_ID, TestCaseResolutionStatusTypes.Assigned, + IncidentWorkflowStages.RESOLVED_STAGE_ID, TestCaseResolutionStatusTypes.Resolved); private static final String TEST_CASE_TYPE = "testCase"; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResolutionStatusRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResolutionStatusRepository.java index b9006bae677c..86ab9bba1612 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResolutionStatusRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResolutionStatusRepository.java @@ -44,6 +44,8 @@ import org.openmetadata.service.resources.dqtests.TestCaseResolutionStatusResource; import org.openmetadata.service.resources.feeds.MessageParser; import org.openmetadata.service.search.SearchListFilter; +import org.openmetadata.service.tasks.IncidentWorkflowStages; +import org.openmetadata.service.tasks.TaskWorkflowLifecycleResolver; import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.RestUtil; import org.openmetadata.service.util.incidentSeverityClassifier.IncidentSeverityClassifierInterface; @@ -568,9 +570,59 @@ private static UUID createIncidentTask(TestCase testCase, String updatedBy) { "Incident task created on test failure: id={}, testCase={}", task.getId(), fullTestCase.getFullyQualifiedName()); + advanceAutoAssignedIncident(taskRepository, task.getId(), assignees, updatedBy); return task.getId(); } + /** + * Moves an incident that was auto-assigned from the test case owners out of the workflow's {@code + * new} stage and into {@code assigned}, by driving the same {@code assign} transition a manual + * assignment uses. + * + *

Without this the incident stays in {@code New}, and because the TCRS mirror only carries + * assignee details on {@code Assigned} records, the Incident Manager renders it as unassigned even + * though the task itself has assignees. + */ + private static void advanceAutoAssignedIncident( + TaskRepository taskRepository, + UUID taskId, + List assignees, + String updatedBy) { + if (!nullOrEmpty(assignees)) { + try { + Task current = taskRepository.get(null, taskId, taskRepository.getFields("*")); + if (canAdvanceToAssignedStage(current)) { + taskRepository.resolveTaskWithWorkflow( + current, + IncidentWorkflowStages.ASSIGN_TRANSITION_ID, + null, + null, + null, + null, + updatedBy); + LOG.info("Incident task {} auto-advanced to the assigned stage", taskId); + } else { + LOG.warn( + "Incident task {} has assignees but sits at stage '{}' instead of '{}'; it stays New and renders as unassigned", + taskId, + current.getWorkflowStageId(), + IncidentWorkflowStages.NEW_STAGE_ID); + } + } catch (Exception e) { + // Best effort: an incident that fails to advance is still a usable incident sitting in + // New, so never fail test result ingestion over it. + LOG.warn("Failed to auto-advance incident task {} to the assigned stage", taskId, e); + } + } + } + + private static boolean canAdvanceToAssignedStage(Task task) { + return IncidentWorkflowStages.NEW_STAGE_ID.equals(task.getWorkflowStageId()) + && TaskWorkflowLifecycleResolver.findTransition( + task, IncidentWorkflowStages.ASSIGN_TRANSITION_ID) + != null; + } + private void setResolutionMetrics( TestCaseResolutionStatus lastIncident, TestCaseResolutionStatus newIncident) { List metrics = new ArrayList<>(); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/tasks/IncidentWorkflowStages.java b/openmetadata-service/src/main/java/org/openmetadata/service/tasks/IncidentWorkflowStages.java new file mode 100644 index 000000000000..bd7a1fa5bc60 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/tasks/IncidentWorkflowStages.java @@ -0,0 +1,31 @@ +/* + * Copyright 2026 Collate + * Licensed 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 org.openmetadata.service.tasks; + +/** + * Stage and transition identifiers declared by the {@code TestCaseResolutionTaskWorkflow} seed + * definition. Kept in one place so the code paths that mirror or drive that workflow — + * {@code IncidentTcrsSyncHandler} and the incident auto-assignment path — cannot drift apart. + */ +public final class IncidentWorkflowStages { + public static final String NEW_STAGE_ID = "new"; + public static final String ACK_STAGE_ID = "ack"; + public static final String ASSIGNED_STAGE_ID = "assigned"; + public static final String RESOLVED_STAGE_ID = "resolved"; + + public static final String ASSIGN_TRANSITION_ID = "assign"; + + private IncidentWorkflowStages() {} +}