Skip to content

Added handling for insert and insertIfNotExists rule consequence#749

Merged
spoorthipujariadobe merged 13 commits intoadobe:feature/disqualificationfrom
spoorthipujariadobe:feature/disqualification
May 1, 2025
Merged

Added handling for insert and insertIfNotExists rule consequence#749
spoorthipujariadobe merged 13 commits intoadobe:feature/disqualificationfrom
spoorthipujariadobe:feature/disqualification

Conversation

@spoorthipujariadobe
Copy link
Copy Markdown
Contributor

@spoorthipujariadobe spoorthipujariadobe commented Apr 1, 2025

Description

This PR implements support for the new insert and insertIfNotExists consequence support in the Core RulesEngine, leveraging the existing rules condition evaluation logic to trigger these consequences.

The consequence handling has been updated to handle the new schema consequence type, and https://ns.adobe.com/personalization/eventHistoryOperation schema value.

At a high level, the consequence object has two high level properties:

Name Key Type Description
Operation type operation string Required
Determines the type of database operation to be performed on Event History by the SDK.

- Insert: Inserts a record in the Event History Database.
- Insert if not exists: Inserts a record in the Event History Database only if there is not already a matching record with a matching hash value.
Key-value pairs content object Optional
Provided key-value pairs will be added to the string used to generate the event history hash.

If the value represents a token that the SDK knows how to resolve, the resolved value will be used (such as shared state values, ~timestampu, etc.).

The primary logic for this new support is handled in the new LaunchRulesConsequence processEventHistoryOperation method:

  1. Checks that the required property of operation exists (which could be either insert or insertIfNotExists, but does not strictly require these two values for future expansion)
  2. The consequence content can have values which are tokens. Token values are reformatted and resolved using the LaunchTokenFinder before the consequence processing begins.
  3. The historical event to be recorded has all the key-value pairs in content, including replaced token values, as its event data. The event mask is set up with all of the keys from content.
  4. If insertIfNotExists, first check that the hash for the event does not already exist
    • NOTE: A new extension function on Event, toEventHistoryRequest has been created in to support easily convert the event data to EventHistoryRequest by flattening the event data map and extracting all the key value pairs from it, as is required by the existing getEvents API's input format requirement
  5. Insert the historical event into the record
  6. On successful insert, also dispatch the event recorded as a rules consequence event so extensions dependent on event history inserts can respond

Related Issue

Motivation and Context

How Has This Been Tested?

Screenshots (if appropriate):

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • I have signed the Adobe Open Source CLA.
  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 11, 2025

Codecov Report

Attention: Patch coverage is 84.00000% with 20 lines in your changes missing coverage. Please review.

Please upload report for BASE (feature/disqualification@0b11523). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...obile/launch/rulesengine/LaunchRulesConsequence.kt 86.61% 9 Missing and 6 partials ⚠️
...ing/mobile/internal/eventhub/ExtensionContainer.kt 0.00% 3 Missing ⚠️
...e/marketing/mobile/internal/util/EventExtension.kt 88.89% 0 Missing and 1 partial ⚠️
...m/adobe/marketing/mobile/internal/util/MapUtils.kt 0.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@                     Coverage Diff                     @@
##             feature/disqualification     #749   +/-   ##
===========================================================
  Coverage                            ?   74.84%           
  Complexity                          ?     2323           
===========================================================
  Files                               ?      217           
  Lines                               ?    10789           
  Branches                            ?     1402           
===========================================================
  Hits                                ?     8075           
  Misses                              ?     2071           
  Partials                            ?      643           
Flag Coverage Δ
android-functional-tests 27.86% <0.00%> (?)
android-unit-tests 64.01% <84.00%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
.../java/com/adobe/marketing/mobile/ExtensionApi.java 100.00% <ø> (ø)
...be/marketing/mobile/internal/util/MapExtensions.kt 81.82% <ø> (ø)
...e/marketing/mobile/internal/util/EventExtension.kt 88.89% <88.89%> (ø)
...m/adobe/marketing/mobile/internal/util/MapUtils.kt 0.00% <0.00%> (ø)
...ing/mobile/internal/eventhub/ExtensionContainer.kt 53.17% <0.00%> (ø)
...obile/launch/rulesengine/LaunchRulesConsequence.kt 85.00% <86.61%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@spoorthipujariadobe spoorthipujariadobe marked this pull request as ready for review April 12, 2025 00:54
Copy link
Copy Markdown
Contributor

@timkimadobe timkimadobe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Spoorthi! Sorry for the delay in reviewing

Question semi-related to this feature, but not touched in this PR:
I noticed that in AndroidEventHistory's recordEvent:
val fnv1aHash = convertMapToFnv1aHash(event.eventData, event.mask) can return -1 if event data is null, but this is not handled in the method and inserts into event history, is this the intended behavior?

In iOS, the default is 0 and the event is not recorded: https://github.com/timkimadobe/aepsdk-core-ios/blob/872b423b3d7767839b050cdfb9eced9ee6480a2c/AEPCore/Sources/eventhub/Event.swift#L65

override fun recordEvent(event: Event, handler: EventHistoryResultHandler<Boolean>?) {
executor.submit {
val fnv1aHash = convertMapToFnv1aHash(event.eventData, event.mask)
Log.debug(
CoreConstants.LOG_TAG,
LOG_TAG,
"%s hash($fnv1aHash) for Event(${event.uniqueIdentifier})",
if (fnv1aHash == 0L) "Not Recording" else "Recording"
)
val res = if (fnv1aHash != 0L) {

cc @sbenedicadb

Also sorry haven't fully reviewed the test cases, will do that as I implement them in iOS

}

override fun recordHistoricalEvent(event: Event, handler: EventHistoryResultHandler<Boolean>) {
EventHub.shared.eventHistory?.recordEvent(event, handler)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To Steve's point on my PR in iOS, should we add a call to the handler here with false if eventHistory is null? And similarly for getHistoricalEvents too: https://github.com/adobe/aepsdk-core-ios/pull/1121/files/166478db789731592a63b0e58bf4fa950b84eaf1#r2017691177

private const val EVENT_HISTORY_OPERATION_KEY = "operation"
private const val EVENT_HISTORY_KEYS_KEY = "keys"
private const val EVENT_HISTORY_CONTENT_KEY = "content"
private const val EVENT_HISTORY_TOKEN_PREFIX = "~"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forgot to remove this in mine, but with the removal of keys support, I think we can remove:
EVENT_HISTORY_TOKEN_PREFIX and EVENT_HISTORY_KEYS_KEY

}

// Note `content` doesn't need to be resolved here because it was already resolved by LaunchRulesConsequence.process(event: Event, matchedRules: List<LaunchRule>)
val content = DataReader.getTypedMap(Any::class.java, schemaData, EVENT_HISTORY_CONTENT_KEY)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't getTypedMap throw on cast failure? The Kotlin side would need to handle any throws right?:

if (!(value instanceof Map)) {
throw new DataReaderException("Value is not a map");
}

Just an idea but something along the lines of:

val content: Map<String, Any>?
try {
    content = DataReader.getTypedMap(Any::class.java, schemaData, EVENT_HISTORY_CONTENT_KEY)
} catch (e: DataReaderException) {
    Log.warning(
        LaunchRulesEngineConstants.LOG_TAG,
        logTag,
        "Unable to process eventHistoryOperation operation for consequence ${consequence.id}, 'content' is either missing or improperly formatted in 'details.data': ${e.localizedMessage}"
    )
    return
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! I will change to use optTypedMap and return null so the check on the next line causes a fast fail

get() = detail?.get("schema") as? String

private val RuleConsequence.detailData: Map<String, Any>?
get() = DataReader.getTypedMap(Any::class.java, detail, "data")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cast with potential throw also needs to be handled right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will change it to use optTypedMap since we already have a null check for detailData

}

@Test
fun testRecordHistoricalEvent() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry not sure if this is covered already somewhere else, but could we also add a case that tests when recording an event without a mask applied?

val flattenedData = eventData?.flattening() ?: emptyMap()

// Filter the flattened data based on mask if provided
val filteredData: Map<String, Any?> = if (!mask.isNullOrEmpty()) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is out of scope for this PR, but I noticed that there is a logic difference between iOS and Android in general for the fnv1a32 method:

Looking at the history for Android changes, it has been like this for a while, even before the Kotlin conversion:

It's a breaking change to reconcile them, but should we make them consistent?

Copy link
Copy Markdown
Contributor Author

@spoorthipujariadobe spoorthipujariadobe Apr 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed the implementation in the newly added toEventHistoryRequest extension function. Discussing offline about changing the existing fnv1a32 Map extension function since its a breaking change

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed the MapExtension. fnv1a32 to not write to event history if mask is empty map

false
) { count ->
eventCounts = count
latch.countDown()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Woah did not know this existed! Thanks Prashanth

Copy link
Copy Markdown
Contributor Author

@spoorthipujariadobe spoorthipujariadobe Apr 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same, thanks! Will replace it
On closer inspection the linked wrapper function has a limitation where in case of a database look up exception, the method returns 0 instead of throwing the exception. The insertIfNotExists logic would understand the 0 as event not existing in history and go ahead with the insertion, which is the opposite of what we want. Will leave the logic as is and create a Github issue to fix this wrapper function

* @param to the end time that represents the upper bounds of the date range used when looking up an Event
* @return an [EventHistoryRequest] with mask derived from this event's data
*/
internal fun Event.toEventHistoryRequest(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be moved to com.adobe.marketing.mobile.internal.util as EventExtensions is this only being used internally.

Comment on lines +394 to +395
if (StringUtils.isNullOrEmpty(consequence.detailId) ||
StringUtils.isNullOrEmpty(consequence.schema) ||
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: consequence.detailId.isNullOrEmpty() .. , as we have been using kotlin extensions where available.

.chainToParentEvent(parentEvent)
.build()

when (operation) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this when statement has only 2 clauses, one of which is else - transitioning to the following structure will be easier to follow.

if (!(CONSEQUENCE_EVENT_HISTORY_OPERATION_INSERT || CONSEQUENCE_EVENT_HISTORY_OPERATION_INSERT_IF_NOT_EXISTS))) {
 return;

}

if (CONSEQUENCE_EVENT_HISTORY_OPERATION_INSERT_IF_NOT_EXISTS) {
    // do some pre-reqs
}

// rest of your processing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also implemented this as a switch in the iOS implementation with the intention of making any future cases easy to add on - would you say that this if structure handles the current case better? Would a switch be preferable if there were actually more cases? Or ifs would still be preferable?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally I see it this way -

  • Try and not nest where possible.
  • when and switch blocks are preferable for maintainability if there are multiple mutually exclusive cases.
  • In case there is just one clause use a simple if/else for readability.

In this particular code, two cases are combined within a when, but still we still need an if inside to distinguish one, causing nesting on the condition on which the when was applied on.
So switching this to an early return pattern seemed cleaner because it reads linearly - Not interested in anything other than X and Y. If X, do something first. Then do shared processing for X and Y.

If we were to add more such cases, we can think of having a pre-processing step as a separate method which takes in the operation, goes through the when block on it and decides what has to be done before passing this off to actual processing.

fun handleOperation() {
    preprocess(operation)
    process(operation)
}

fun preprocess(operation) {
   when(operation) {
       L,M -> { common flow for L,M  }
       X -> { }
       Y -> { }
       else -> { }
    }
    
}


fun process(operation) {
     when(operation) {
       L -> { }
       M -> { }
       X -> { }
       Y -> { }
       else -> { }
      }
}

Copy link
Copy Markdown
Contributor Author

@spoorthipujariadobe spoorthipujariadobe Apr 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For sake of readability I changed it to use if-else for now

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One question - instead of a outer negated OR, what if we use not equals ANDed together?

so instead of:

if (!(CONSEQUENCE_EVENT_HISTORY_OPERATION_INSERT || CONSEQUENCE_EVENT_HISTORY_OPERATION_INSERT_IF_NOT_EXISTS))) 
if (!CONSEQUENCE_EVENT_HISTORY_OPERATION_INSERT && !CONSEQUENCE_EVENT_HISTORY_OPERATION_INSERT_IF_NOT_EXISTS))) 

I feel like the intent could be more intuitive when read?

)
return
}
when (consequence.schema) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommend switching to if/else

@spoorthipujariadobe
Copy link
Copy Markdown
Contributor Author

spoorthipujariadobe commented Apr 17, 2025

Thanks Spoorthi! Sorry for the delay in reviewing

Question semi-related to this feature, but not touched in this PR: I noticed that in AndroidEventHistory's recordEvent: val fnv1aHash = convertMapToFnv1aHash(event.eventData, event.mask) can return -1 if event data is null, but this is not handled in the method and inserts into event history, is this the intended behavior?

In iOS, the default is 0 and the event is not recorded: https://github.com/timkimadobe/aepsdk-core-ios/blob/872b423b3d7767839b050cdfb9eced9ee6480a2c/AEPCore/Sources/eventhub/Event.swift#L65

override fun recordEvent(event: Event, handler: EventHistoryResultHandler<Boolean>?) {
executor.submit {
val fnv1aHash = convertMapToFnv1aHash(event.eventData, event.mask)
Log.debug(
CoreConstants.LOG_TAG,
LOG_TAG,
"%s hash($fnv1aHash) for Event(${event.uniqueIdentifier})",
if (fnv1aHash == 0L) "Not Recording" else "Recording"
)
val res = if (fnv1aHash != 0L) {

cc @sbenedicadb

Also sorry haven't fully reviewed the test cases, will do that as I implement them in iOS

Discussed offline and changed the method convertMapToFnv1aHash to return 0 in case of null map

consequence.schema.isNullOrBlank() ||
consequence.detailData.isNullOrEmpty()
) {
Log.error(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I missed this earlier, but I believe Steve recommended changing these to warning instead of error: https://github.com/adobe/aepsdk-core-ios/pull/1121/files/f0d428b1e99eed5972634b0ab72c68bd8de7a801#r2017715694

*/
private fun processEventHistoryOperation(consequence: RuleConsequence, parentEvent: Event) {
val schemaData = consequence.detailData ?: run {
Log.error(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning level here too

if (result) {
extensionApi.dispatch(eventToRecord)
} else {
Log.trace(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be warning level?

Log.warning(
LaunchRulesEngineConstants.LOG_TAG,
logTag,
"Event History operation for id ${consequence.id} - Unable to process 'insertIfNotExists' operation, event hash is 0}"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: i think theres a typo } at the end of the log string?

Copy link
Copy Markdown
Contributor

@timkimadobe timkimadobe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the delay in reviewing the test cases Spoorthi! Updated with some feedback

// "data": {
// "operation": "insert",
// "content": {
// "key1": "value1"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I know it doesn't affect what is being validated but the actual JSON file has:

"content": {
  "key1": "value1",
  "key2": "value2"
}

🙈

Comment on lines +390 to +391
if (consequence.detailId.isNullOrBlank() ||
consequence.schema.isNullOrBlank() ||
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After going through the test cases, I believe this should be only a null check?
The spec for the rules consequence schema type only specifies that it must be a valid String

And in both iOS and Android Messaging, empty string values for both id and schema are also not interpreted as error cases:

iOS

fromRuleConsequenceEvent:

static func fromRuleConsequenceEvent(_ event: Event) -> PropositionItem? {
    guard let id = event.schemaId, let schema = event.schemaType, let schemaData = event.schemaData else {
        return nil
    }

    return PropositionItem(itemId: id, schema: schema, itemData: schemaData)
}

Event+Messaging helpers:

var schemaId: String? {
    details?[MessagingConstants.Event.Data.Key.ID] as? String
}

var schemaType: SchemaType? {
    guard let schemaString = details?[MessagingConstants.Event.Data.Key.SCHEMA] as? String else {
        return nil
    }

    return SchemaType(from: schemaString)
}

Android

fromRuleConsequenceDetail:

static PropositionItem fromRuleConsequenceDetail(final Map<String, Object> consequenceDetail) {
    PropositionItem propositionItem = null;
    try {
        final String uniqueId =
                DataReader.getString(
                        consequenceDetail, MessagingConstants.ConsequenceDetailKeys.ID);
        final SchemaType schema =
                SchemaType.fromString(
                        DataReader.getString(
                                consequenceDetail,
                                MessagingConstants.ConsequenceDetailKeys.SCHEMA));
...

private fun processSchemaConsequence(consequence: RuleConsequence, parentEvent: Event) {
if (consequence.detailId.isNullOrBlank() ||
consequence.schema.isNullOrBlank() ||
consequence.detailData.isNullOrEmpty()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the spec also mentions that the detail data is allowed to be empty - should we move this validation to the specific schema handler logic (in this case under processEventHistoryOperation)?

}

@Test
fun `Test Schema Event History Insert Operation When detail id Is Empty`() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the implementation is updated to accept empty string for id, can we update this test to pass instead?

},
"consequences": [
{
"type": "schema",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This consequence is missing the top level id field and I believe causes the test to fail at the consequence construction stage, not the detail -> id check stage

}

@Test
fun `Test Schema Event History Insert Operation When detail id Is Null`() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also create a test case that covers when the detail "id" = null case?

}

@Test
fun `Test Schema Event History Insert Operation When detail schema Is Null`() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thing for schema, can we create another test case that validate the "schema" = null case?

}

@Test
fun `Test Schema Event History Insert Operation When detail data Is Null`() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thing for data, can we create another test case that validate the "data" = null case?

}

@Test
fun `Test Schema Event History Insert Operation When Operation Is Null`() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we group these cases with the other missing or null property check cases? So overall the flow could be like:

  • Property validation checks
    • Schema checks
    • Schema data checks
    • Schema data property checks
  • insert logical checks
  • insertIfNotExists logical checks
  • Event History failure checks

And can we also add a case for "operation" = null?

}

@Test
fun `Test Schema Event History Insert Operation When Content Is Null`() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a case for "content" = null?

Comment on lines +473 to +478
val eventHash = eventToRecord.eventData.fnv1a32()
if (eventHash == 0L) {
Log.warning(
LaunchRulesEngineConstants.LOG_TAG,
logTag,
"Event History operation for id ${consequence.id} - Unable to process 'insertIfNotExists' operation, event hash is 0"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move this check to before the if check for insertIfNotExists? Since this requirement exists for both cases?

)
return
}
if (eventCounts >= 1) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also on second thought for this condition, do we also want to check that the eventCounts is also not -1? Since on database error, AndroidEventHistory getEvents sets count to -1 in that case?

I feel like it might be better to handle the database error case by not trying to record than to record?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! I've added the check as well as all the tests and comments mentioned above

Copy link
Copy Markdown
Contributor

@timkimadobe timkimadobe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@spoorthipujariadobe spoorthipujariadobe merged commit b197f79 into adobe:feature/disqualification May 1, 2025
41 of 42 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants