Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions api/integrations/launch_darkly/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,14 @@ def _ld_operator_to_flagsmith_operator(ld_operator: str) -> Optional[ConditionOp
}.get(ld_operator, None)


def _to_unanchored_regex(pattern: str) -> str:
# Launch Darkly searches for `matches` patterns anywhere in the value, while Flagsmith matches from the
# start only. Leading inline flags such as `(?i)` must stay at the start of the expression.
flags = re.match(r"(?:\(\?[a-zA-Z]+\))+", pattern)
prefix = flags.group() if flags else ""
return f"{prefix}.*(?:{pattern[len(prefix) :]})"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor · Functional Correctness · Quick win

Match across newlines in the prefix.

If a trait value is "first\nbar" and the LaunchDarkly pattern is bar, LaunchDarkly can find bar, but .*(?:bar) cannot reach it. . does not match a newline by default. Use a newline-matching prefix, such as (?s:.*), without changing the flags of the imported pattern. Add a multiline case to the segment-membership test. (launchdarkly.com)

Proposed change
--- "a/api/integrations/launch_darkly/services.py"
+++ "b/api/integrations/launch_darkly/services.py"
@@ -179,7 +179,7 @@
     # start only. Leading inline flags such as `(?i)` must stay at the start of the expression.
     flags = re.match(r"(?:\(\?[a-zA-Z]+\))+", pattern)
     prefix = flags.group() if flags else ""
-    return f"{prefix}.*(?:{pattern[len(prefix) :]})"
+    return f"{prefix}(?s:.*)(?:{pattern[len(prefix) :]})"
 
 
 def _convert_ld_values(values: list[str], ld_operator: str) -> list[str]:
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return f"{prefix}.*(?:{pattern[len(prefix) :]})"
return f"{prefix}(?s:.*)(?:{pattern[len(prefix) :]})"



def _convert_ld_values(values: list[str], ld_operator: str) -> list[str]:
"""
Convert "values" of a Launch Darkly clause to Flagsmith compatible values. Some matching is converted to
Expand Down Expand Up @@ -201,6 +209,8 @@ def _convert_ld_values(values: list[str], ld_operator: str) -> list[str]:
return [".*" + re.escape(value) + "$" for value in values]
case "startsWith":
return ["^" + re.escape(value) for value in values]
case "matches":
return [_to_unanchored_regex(value) for value in values]
case "semVerEqual" | "semVerLessThan" | "semVerGreaterThan":
return [value + ":semver" for value in values]
case _:
Expand Down
49 changes: 47 additions & 2 deletions api/tests/unit/integrations/launch_darkly/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ def test_process_import_request__valid_segments__creates_segment_per_environment
{
"property": "p3",
"operator": segment_constants.REGEX,
"value": "foo[0-9]{0,1}",
"value": ".*(?:foo[0-9]{0,1})",
"description": None,
}
],
Expand Down Expand Up @@ -511,6 +511,51 @@ def test_process_import_request__ends_with_clause__matches_suffix_only(
assert ("Dynamic List (Override for test)" in segment_names) is expected_in_segment


@pytest.mark.parametrize(
"pattern, email, expected_in_segment",
[
(r"gmail\.com", "gmail.com", True),
(r"gmail\.com", "user@gmail.com", True),
(r"gmail\.com", "user@example.org", False),
(r"^gmail|corp", "user@corp.com", True),
(r"^gmail|corp", "user@gmail.com", False),
(r"(?i)GMAIL\.com", "user@gmail.com", True),
],
)
@pytest.mark.django_db(transaction=True)
def test_process_import_request__matches_clause__matches_anywhere_in_value(
project: Project,
import_request: LaunchDarklyImportRequest,
ld_client_mock: MagicMock,
pattern: str,
email: str,
expected_in_segment: bool,
) -> None:
# Given
segments = [
segment
for segment in ld_client_mock.get_segments.return_value
if segment["key"] == "dynamic-list"
]
segments[0]["rules"][0]["clauses"][0].update(op="matches", values=[pattern])
ld_client_mock.get_segments.return_value = segments
process_import_request(import_request)
environment = Environment.objects.get(project=project, name="Test")
identity = Identity.objects.create(identifier="user", environment=environment)
Trait.objects.create(
identity=identity,
trait_key="email",
value_type="unicode",
string_value=email,
)

# When
segment_names = {segment.name for segment in identity.get_segments()}

# Then
assert ("Dynamic List (Override for test)" in segment_names) is expected_in_segment


@pytest.mark.django_db(transaction=True)
def test_process_import_request__valid_segments__creates_identities_with_key_traits(
project: Project,
Expand Down Expand Up @@ -648,7 +693,7 @@ def test_process_import_request__valid_segments__imports_correctly_x_replaced_ab
"property", "operator", "value"
)
) == {
("p3", segment_constants.REGEX, "foo[0-9]{0,1}"),
("p3", segment_constants.REGEX, ".*(?:foo[0-9]{0,1})"),
}

# Include individual users
Expand Down
Loading