From 650064e330f93cb403aeaf5f22ad194d1457780a Mon Sep 17 00:00:00 2001 From: Pablo Takara Date: Tue, 28 Jul 2026 16:46:06 +0200 Subject: [PATCH 1/2] 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. --- .../it/tests/AutoAssignIncidentIT.java | 269 ++++++++++++++++++ .../handlers/IncidentTcrsSyncHandler.java | 9 +- .../TestCaseResolutionStatusRepository.java | 46 +++ .../service/tasks/IncidentWorkflowStages.java | 31 ++ 4 files changed, 351 insertions(+), 4 deletions(-) create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/AutoAssignIncidentIT.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/tasks/IncidentWorkflowStages.java 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..690df35248c4 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/AutoAssignIncidentIT.java @@ -0,0 +1,269 @@ +/* + * 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.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); + assertTrue( + assigned.getTestCaseResolutionStatusDetails().toString().contains(owner.getName()), + "Assigned TCRS record should carry 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 6e35b3f3cf73..1689de235504 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 @@ -78,10 +79,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..22d15a5f01e2 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,53 @@ 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); + } + } 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() {} +} From ef9c3a30681e70713b81a056ee513a9b38100b68 Mon Sep 17 00:00:00 2001 From: Pablo Takara Date: Tue, 28 Jul 2026 17:16:08 +0200 Subject: [PATCH 2/2] 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. --- .../openmetadata/it/tests/AutoAssignIncidentIT.java | 11 ++++++++--- .../jdbi3/TestCaseResolutionStatusRepository.java | 6 ++++++ 2 files changed, 14 insertions(+), 3 deletions(-) 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 index 690df35248c4..ac1bbb6a21a7 100644 --- 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 @@ -43,6 +43,7 @@ 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; @@ -102,9 +103,13 @@ void ownedTestCase_failedResult_incidentAutoAssigned(TestNamespace ns) { TestCaseResolutionStatus assigned = findTcrsOfType(client, stateId, TestCaseResolutionStatusTypes.Assigned); - assertTrue( - assigned.getTestCaseResolutionStatusDetails().toString().contains(owner.getName()), - "Assigned TCRS record should carry the owner as assignee"); + 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 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 22d15a5f01e2..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 @@ -601,6 +601,12 @@ private static void advanceAutoAssignedIncident( 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