From 99cf11420a78ae71f2f3395fa35d83c5bd7eeda4 Mon Sep 17 00:00:00 2001 From: Harley Alexander Date: Thu, 17 Sep 2026 13:50:45 +0100 Subject: [PATCH 1/9] fix(workflows): match github trigger repositories case-insensitively --- products/workflows/backend/api/hog_flow.py | 20 ++++ .../backend/api/test/test_hog_flow.py | 26 +++++ .../backend/github_workflow_events.py | 4 +- ...026_lowercase_github_repository_filters.py | 101 ++++++++++++++++++ .../backend/migrations/max_migration.txt | 2 +- .../test/test_github_workflow_events.py | 4 +- 6 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 products/workflows/backend/migrations/0026_lowercase_github_repository_filters.py diff --git a/products/workflows/backend/api/hog_flow.py b/products/workflows/backend/api/hog_flow.py index 2fba414aec0e..79d0a3785397 100644 --- a/products/workflows/backend/api/hog_flow.py +++ b/products/workflows/backend/api/hog_flow.py @@ -830,6 +830,25 @@ def _normalize_slack_channel_filters(filters: dict) -> None: prop["value"] = [item.split("|")[0] if isinstance(item, str) else item for item in value] +def _normalize_github_repository_filters(filters: dict) -> None: + """Lowercase a `repository` filter value in place, to match the lowercased delivery property. + + GitHub treats "PostHog/posthog" and "posthog/posthog" as the same repository, but the exact + filter does not, so a name typed in the wrong case compiles to a trigger that never fires. + """ + properties = filters.get("properties") + if not isinstance(properties, list): + return + for prop in properties: + if not isinstance(prop, dict) or prop.get("key") != "repository": + continue + value = prop.get("value") + if isinstance(value, str): + prop["value"] = value.lower() + elif isinstance(value, list): + prop["value"] = [item.lower() if isinstance(item, str) else item for item in value] + + # Exact is the only operator that names channels. Channel ids are opaque (C0...), so # substring and regex matching can't narrow meaningfully and patterns like ".*" or "C" # match every channel; presence operators match every message and carry the operator @@ -1576,6 +1595,7 @@ def _subscribes_to(event_id: str) -> bool: } ) if _subscribes_to("$github_event_received"): + _normalize_github_repository_filters(filters) if not is_draft and not _has_exact_string_filter(filters, "repository"): raise serializers.ValidationError( { diff --git a/products/workflows/backend/api/test/test_hog_flow.py b/products/workflows/backend/api/test/test_hog_flow.py index 6a7b3ba20d56..b63638dc6bd1 100644 --- a/products/workflows/backend/api/test/test_hog_flow.py +++ b/products/workflows/backend/api/test/test_hog_flow.py @@ -2629,6 +2629,32 @@ def test_hog_flow_internal_event_trigger_stores_the_bare_slack_channel_id(self): stored = response.json()["trigger"]["filters"]["properties"][0]["value"] assert stored == ["C0ALERTS"] + def test_hog_flow_github_trigger_stores_the_repository_lowercased(self): + # GitHub deliveries carry the lowercased full name, so a filter typed in the org's + # casing compiles to an exact match that never fires. + trigger_action = { + "id": "trigger_node", + "name": "trigger_1", + "type": "trigger", + "config": { + "type": "internal-event", + "filters": { + "events": [{"id": "$github_event_received", "type": "events"}], + "properties": [ + {"key": "repository", "value": ["PostHog/PostHog"], "operator": "exact", "type": "event"}, + {"key": "event_type", "value": ["issues"], "operator": "exact", "type": "event"}, + ], + }, + }, + } + + hog_flow = {"name": "Test GitHub Flow", "status": "active", "actions": [trigger_action]} + + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", hog_flow) + assert response.status_code == 201, response.json() + stored = response.json()["trigger"]["filters"]["properties"][0]["value"] + assert stored == ["posthog/posthog"] + @staticmethod def _slack_trigger_action(properties: list[dict]) -> dict: return { diff --git a/products/workflows/backend/github_workflow_events.py b/products/workflows/backend/github_workflow_events.py index fd573d066696..25b3101b2e55 100644 --- a/products/workflows/backend/github_workflow_events.py +++ b/products/workflows/backend/github_workflow_events.py @@ -134,7 +134,9 @@ def _event_properties(event_type: str, payload: dict[str, Any], *, integration_i "integration_id": integration_id, "event_type": event_type, "action": payload.get("action"), - "repository": repository.get("full_name"), + # Lowercased on both sides of the match (the API lowercases a repository filter on save), so + # "PostHog/posthog" and "posthog/posthog" name the same repository like they do on GitHub. + "repository": (repository.get("full_name") or "").lower() or None, # A string, not a boolean: GitHub deliveries never reach ClickHouse, so a filter has no # stored property definition to coerce "true"/"false" back into a real boolean, and an # exact match against a raw boolean here would never match. diff --git a/products/workflows/backend/migrations/0026_lowercase_github_repository_filters.py b/products/workflows/backend/migrations/0026_lowercase_github_repository_filters.py new file mode 100644 index 000000000000..3e13ed6fee60 --- /dev/null +++ b/products/workflows/backend/migrations/0026_lowercase_github_repository_filters.py @@ -0,0 +1,101 @@ +from django.db import migrations + +BATCH_SIZE = 1000 + +GITHUB_EVENT_RECEIVED_EVENT = "$github_event_received" + + +def _lowercased_filters(filters: object) -> dict | None: + """The filters with every `repository` value lowercased, or None when nothing changes. + + The compiled bytecode holds the same strings as constants, so they are rewritten too rather + than recompiled; a value string reused by another filter on the same trigger is rare enough + to accept. + """ + if not isinstance(filters, dict): + return None + events = filters.get("events") + if not isinstance(events, list) or not any( + isinstance(event, dict) and event.get("id") == GITHUB_EVENT_RECEIVED_EVENT for event in events + ): + return None + renamed: dict[str, str] = {} + properties = [] + for prop in filters.get("properties") or []: + if isinstance(prop, dict) and prop.get("key") == "repository": + value = prop.get("value") + values = value if isinstance(value, list) else [value] + renamed.update({item: item.lower() for item in values if isinstance(item, str) and item != item.lower()}) + lowered = [item.lower() if isinstance(item, str) else item for item in values] + prop = {**prop, "value": lowered if isinstance(value, list) else lowered[0]} + properties.append(prop) + if not renamed: + return None + bytecode = filters.get("bytecode") + if isinstance(bytecode, list): + bytecode = [renamed.get(op, op) if isinstance(op, str) else op for op in bytecode] + return {**filters, "properties": properties, "bytecode": bytecode} + + +def _lowercased_config(config: object) -> dict | None: + if not isinstance(config, dict) or config.get("type") != "internal-event": + return None + filters = _lowercased_filters(config.get("filters")) + return None if filters is None else {**config, "filters": filters} + + +def _lowercased_actions(actions: object) -> list | None: + if not isinstance(actions, list): + return None + changed = False + result = [] + for action in actions: + config = ( + _lowercased_config(action.get("config")) + if isinstance(action, dict) and action.get("type") == "trigger" + else None + ) + if config is not None: + action = {**action, "config": config} + changed = True + result.append(action) + return result if changed else None + + +def lowercase_github_repository_filters(apps, schema_editor): + """Publishing a draft or restoring a revision re-runs the serializer, which lowercases on its own, + so only the live trigger and its action need rewriting here.""" + HogFlow = apps.get_model("workflows", "HogFlow") + db_alias = schema_editor.connection.alias + flows_to_update = [] + + for row in ( + HogFlow.objects.using(db_alias) + .filter(trigger__type="internal-event") + .order_by("pk") + .values("pk", "trigger", "actions") + .iterator(chunk_size=BATCH_SIZE) + ): + trigger = _lowercased_config(row["trigger"]) + actions = _lowercased_actions(row["actions"]) + if trigger is None and actions is None: + continue + flows_to_update.append( + HogFlow(pk=row["pk"], trigger=trigger or row["trigger"], actions=actions or row["actions"]) + ) + if len(flows_to_update) == BATCH_SIZE: + HogFlow.objects.using(db_alias).bulk_update(flows_to_update, ["trigger", "actions"], batch_size=BATCH_SIZE) + flows_to_update = [] + + if flows_to_update: + HogFlow.objects.using(db_alias).bulk_update(flows_to_update, ["trigger", "actions"], batch_size=BATCH_SIZE) + + +class Migration(migrations.Migration): + dependencies = [ + ("workflows", "0025_hogflow_email_sending_paused_by"), + ] + + operations = [ + migrations.RunPython(lowercase_github_repository_filters, migrations.RunPython.noop), + ] diff --git a/products/workflows/backend/migrations/max_migration.txt b/products/workflows/backend/migrations/max_migration.txt index 4aa215fa754c..6eddb7ef596b 100644 --- a/products/workflows/backend/migrations/max_migration.txt +++ b/products/workflows/backend/migrations/max_migration.txt @@ -1 +1 @@ -0025_hogflow_email_sending_paused_by +0026_lowercase_github_repository_filters diff --git a/products/workflows/backend/test/test_github_workflow_events.py b/products/workflows/backend/test/test_github_workflow_events.py index bc3239c1192d..e2072eb657db 100644 --- a/products/workflows/backend/test/test_github_workflow_events.py +++ b/products/workflows/backend/test/test_github_workflow_events.py @@ -238,7 +238,7 @@ def test_oversized_delivery_sheds_the_raw_payload_but_still_emits(produce, integ properties = produce.call_args.args[1].properties assert properties["github_event"] == {"truncated": True} # The flat fields a trigger filters on still deliver, so the run isn't lost. - assert properties["repository"] == "PostHog/posthog" + assert properties["repository"] == "posthog/posthog" assert properties["event_type"] == "push" @@ -273,7 +273,7 @@ def test_properties_carry_what_a_filter_needs(produce, integration) -> None: properties = produce.call_args.args[1].properties assert properties["event_type"] == "issues" assert properties["action"] == "opened" - assert properties["repository"] == "PostHog/posthog" + assert properties["repository"] == "posthog/posthog" assert properties["title"] == "The database is on fire" assert properties["github_event"] == ISSUE_EVENT From 33e30f512c66301d3966943be0b9d6a4db0fa691 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:26:36 +0000 Subject: [PATCH 2/9] fix(workflows): lowercase repository filters on github event entries The compiler ANDs the conditions on an event entry with the global ones, so a repository filter written on the $github_event_received entry also decides whether the trigger fires. Both the serializer normalizer and migration 0026 read only the global properties, so a mixed-case value there survived and stopped matching once the delivery property became lowercase. The serializer now normalizes the github event entry's properties too, and the migration walks them when it rewrites a live trigger. A sibling event entry for another event keeps its own values. Generated-By: PostHog Desktop Task-Id: 1d50a3d8-db98-49cf-9e9d-a411c5c7c475 --- products/workflows/backend/api/hog_flow.py | 23 ++++++---- .../backend/api/test/test_hog_flow.py | 23 ++++++++-- ...026_lowercase_github_repository_filters.py | 42 ++++++++++++++----- 3 files changed, 66 insertions(+), 22 deletions(-) diff --git a/products/workflows/backend/api/hog_flow.py b/products/workflows/backend/api/hog_flow.py index 79d0a3785397..4b5548b049a4 100644 --- a/products/workflows/backend/api/hog_flow.py +++ b/products/workflows/backend/api/hog_flow.py @@ -830,13 +830,7 @@ def _normalize_slack_channel_filters(filters: dict) -> None: prop["value"] = [item.split("|")[0] if isinstance(item, str) else item for item in value] -def _normalize_github_repository_filters(filters: dict) -> None: - """Lowercase a `repository` filter value in place, to match the lowercased delivery property. - - GitHub treats "PostHog/posthog" and "posthog/posthog" as the same repository, but the exact - filter does not, so a name typed in the wrong case compiles to a trigger that never fires. - """ - properties = filters.get("properties") +def _lowercase_repository_properties(properties: object) -> None: if not isinstance(properties, list): return for prop in properties: @@ -849,6 +843,21 @@ def _normalize_github_repository_filters(filters: dict) -> None: prop["value"] = [item.lower() if isinstance(item, str) else item for item in value] +def _normalize_github_repository_filters(filters: dict) -> None: + """Lowercase every `repository` filter value in place, to match the lowercased delivery property. + + GitHub treats "PostHog/posthog" and "posthog/posthog" as the same repository, but the exact + filter does not, so a name typed in the wrong case compiles to a trigger that never fires. + The compiler ANDs the conditions on an event entry with the global ones, so a repository + filter written on the entry decides whether the trigger fires too. + """ + _lowercase_repository_properties(filters.get("properties")) + events = filters.get("events") + for event in events if isinstance(events, list) else []: + if isinstance(event, dict) and event.get("id") == "$github_event_received": + _lowercase_repository_properties(event.get("properties")) + + # Exact is the only operator that names channels. Channel ids are opaque (C0...), so # substring and regex matching can't narrow meaningfully and patterns like ".*" or "C" # match every channel; presence operators match every message and carry the operator diff --git a/products/workflows/backend/api/test/test_hog_flow.py b/products/workflows/backend/api/test/test_hog_flow.py index b63638dc6bd1..cf6ae011f303 100644 --- a/products/workflows/backend/api/test/test_hog_flow.py +++ b/products/workflows/backend/api/test/test_hog_flow.py @@ -2631,7 +2631,8 @@ def test_hog_flow_internal_event_trigger_stores_the_bare_slack_channel_id(self): def test_hog_flow_github_trigger_stores_the_repository_lowercased(self): # GitHub deliveries carry the lowercased full name, so a filter typed in the org's - # casing compiles to an exact match that never fires. + # casing compiles to an exact match that never fires. The compiler ANDs the conditions + # on the event entry with the global ones, so a repository named there needs it too. trigger_action = { "id": "trigger_node", "name": "trigger_1", @@ -2639,7 +2640,20 @@ def test_hog_flow_github_trigger_stores_the_repository_lowercased(self): "config": { "type": "internal-event", "filters": { - "events": [{"id": "$github_event_received", "type": "events"}], + "events": [ + { + "id": "$github_event_received", + "type": "events", + "properties": [ + { + "key": "repository", + "value": "PostHog/PostHog", + "operator": "exact", + "type": "event", + } + ], + } + ], "properties": [ {"key": "repository", "value": ["PostHog/PostHog"], "operator": "exact", "type": "event"}, {"key": "event_type", "value": ["issues"], "operator": "exact", "type": "event"}, @@ -2652,8 +2666,9 @@ def test_hog_flow_github_trigger_stores_the_repository_lowercased(self): response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", hog_flow) assert response.status_code == 201, response.json() - stored = response.json()["trigger"]["filters"]["properties"][0]["value"] - assert stored == ["posthog/posthog"] + stored_filters = response.json()["trigger"]["filters"] + assert stored_filters["properties"][0]["value"] == ["posthog/posthog"] + assert stored_filters["events"][0]["properties"][0]["value"] == "posthog/posthog" @staticmethod def _slack_trigger_action(properties: list[dict]) -> dict: diff --git a/products/workflows/backend/migrations/0026_lowercase_github_repository_filters.py b/products/workflows/backend/migrations/0026_lowercase_github_repository_filters.py index 3e13ed6fee60..d66bc0f90b6d 100644 --- a/products/workflows/backend/migrations/0026_lowercase_github_repository_filters.py +++ b/products/workflows/backend/migrations/0026_lowercase_github_repository_filters.py @@ -5,9 +5,26 @@ GITHUB_EVENT_RECEIVED_EVENT = "$github_event_received" +def _lowercased_properties(properties: list, renamed: dict[str, str]) -> list: + """The properties with every `repository` value lowercased, recording each rewrite in `renamed`.""" + result = [] + for prop in properties: + if isinstance(prop, dict) and prop.get("key") == "repository": + value = prop.get("value") + values = value if isinstance(value, list) else [value] + renamed.update({item: item.lower() for item in values if isinstance(item, str) and item != item.lower()}) + lowered = [item.lower() if isinstance(item, str) else item for item in values] + prop = {**prop, "value": lowered if isinstance(value, list) else lowered[0]} + result.append(prop) + return result + + def _lowercased_filters(filters: object) -> dict | None: """The filters with every `repository` value lowercased, or None when nothing changes. + The compiler ANDs the conditions on an event entry with the global ones, so a repository + filter written on the entry decides whether the trigger fires too and needs the same rewrite. + The compiled bytecode holds the same strings as constants, so they are rewritten too rather than recompiled; a value string reused by another filter on the same trigger is rare enough to accept. @@ -20,21 +37,24 @@ def _lowercased_filters(filters: object) -> dict | None: ): return None renamed: dict[str, str] = {} - properties = [] - for prop in filters.get("properties") or []: - if isinstance(prop, dict) and prop.get("key") == "repository": - value = prop.get("value") - values = value if isinstance(value, list) else [value] - renamed.update({item: item.lower() for item in values if isinstance(item, str) and item != item.lower()}) - lowered = [item.lower() if isinstance(item, str) else item for item in values] - prop = {**prop, "value": lowered if isinstance(value, list) else lowered[0]} - properties.append(prop) + rewritten: dict = {**filters} + properties = filters.get("properties") + if isinstance(properties, list): + rewritten["properties"] = _lowercased_properties(properties, renamed) + rewritten["events"] = [ + {**event, "properties": _lowercased_properties(event["properties"], renamed)} + if isinstance(event, dict) + and event.get("id") == GITHUB_EVENT_RECEIVED_EVENT + and isinstance(event.get("properties"), list) + else event + for event in events + ] if not renamed: return None bytecode = filters.get("bytecode") if isinstance(bytecode, list): - bytecode = [renamed.get(op, op) if isinstance(op, str) else op for op in bytecode] - return {**filters, "properties": properties, "bytecode": bytecode} + rewritten["bytecode"] = [renamed.get(op, op) if isinstance(op, str) else op for op in bytecode] + return rewritten def _lowercased_config(config: object) -> dict | None: From 506e85363f66de3670bc0e37e12ba7254a1835aa Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:31:19 +0000 Subject: [PATCH 3/9] fix(workflows): only lowercase literal repository filter values The normalizer picked repository filters by key alone, so it rewrote a pattern the same way it rewrote a literal. `str.lower()` is not a case transform for a regular expression: it turns "\D" into "\d", which inverts the match set, and "(?P" into "(?p", which RE2 rejects. The rewritten value is what compiles, so an author could see a save refused over a pattern they never wrote. Both the serializer normalizer and migration 0026 now rewrite a value only for an operator that compares it as a literal string, treating a missing operator as exact the way the compiler and the repository guard already do. A pattern or presence operator keeps its value. Generated-By: PostHog Desktop Task-Id: 1d50a3d8-db98-49cf-9e9d-a411c5c7c475 --- products/workflows/backend/api/hog_flow.py | 10 ++++++++++ products/workflows/backend/api/test/test_hog_flow.py | 9 +++++++++ .../0026_lowercase_github_repository_filters.py | 11 ++++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/products/workflows/backend/api/hog_flow.py b/products/workflows/backend/api/hog_flow.py index 4b5548b049a4..6f17330bc4c5 100644 --- a/products/workflows/backend/api/hog_flow.py +++ b/products/workflows/backend/api/hog_flow.py @@ -830,12 +830,22 @@ def _normalize_slack_channel_filters(filters: dict) -> None: prop["value"] = [item.split("|")[0] if isinstance(item, str) else item for item in value] +# Lowercasing only preserves meaning for an operator that compares the value as a literal +# string. `str.lower()` is not a case transform for a pattern - it turns "\D" into "\d", which +# inverts what the pattern matches, and "(?P" into "(?p", which RE2 refuses - and a +# presence operator carries the operator string rather than a repository name. The property +# compiler treats a missing operator as exact, as `_has_exact_string_filter` does below. +_LITERAL_REPOSITORY_OPERATORS = frozenset({"exact", "is_not"}) + + def _lowercase_repository_properties(properties: object) -> None: if not isinstance(properties, list): return for prop in properties: if not isinstance(prop, dict) or prop.get("key") != "repository": continue + if (prop.get("operator") or "exact") not in _LITERAL_REPOSITORY_OPERATORS: + continue value = prop.get("value") if isinstance(value, str): prop["value"] = value.lower() diff --git a/products/workflows/backend/api/test/test_hog_flow.py b/products/workflows/backend/api/test/test_hog_flow.py index cf6ae011f303..19fd505b7ded 100644 --- a/products/workflows/backend/api/test/test_hog_flow.py +++ b/products/workflows/backend/api/test/test_hog_flow.py @@ -2657,6 +2657,14 @@ def test_hog_flow_github_trigger_stores_the_repository_lowercased(self): "properties": [ {"key": "repository", "value": ["PostHog/PostHog"], "operator": "exact", "type": "event"}, {"key": "event_type", "value": ["issues"], "operator": "exact", "type": "event"}, + # A pattern is not a literal, so lowercasing it would change what it + # matches, or stop it compiling at all. + { + "key": "repository", + "value": "(?P.+)/PostHog", + "operator": "regex", + "type": "event", + }, ], }, }, @@ -2669,6 +2677,7 @@ def test_hog_flow_github_trigger_stores_the_repository_lowercased(self): stored_filters = response.json()["trigger"]["filters"] assert stored_filters["properties"][0]["value"] == ["posthog/posthog"] assert stored_filters["events"][0]["properties"][0]["value"] == "posthog/posthog" + assert stored_filters["properties"][2]["value"] == "(?P.+)/PostHog" @staticmethod def _slack_trigger_action(properties: list[dict]) -> dict: diff --git a/products/workflows/backend/migrations/0026_lowercase_github_repository_filters.py b/products/workflows/backend/migrations/0026_lowercase_github_repository_filters.py index d66bc0f90b6d..16b609fc6480 100644 --- a/products/workflows/backend/migrations/0026_lowercase_github_repository_filters.py +++ b/products/workflows/backend/migrations/0026_lowercase_github_repository_filters.py @@ -4,12 +4,21 @@ GITHUB_EVENT_RECEIVED_EVENT = "$github_event_received" +# Lowercasing only preserves meaning for an operator that compares the value as a literal string. +# A pattern changes what it matches, or stops compiling, and a presence operator carries the +# operator string rather than a repository name. A missing operator compiles as exact. +LITERAL_REPOSITORY_OPERATORS = frozenset({"exact", "is_not"}) + def _lowercased_properties(properties: list, renamed: dict[str, str]) -> list: """The properties with every `repository` value lowercased, recording each rewrite in `renamed`.""" result = [] for prop in properties: - if isinstance(prop, dict) and prop.get("key") == "repository": + if ( + isinstance(prop, dict) + and prop.get("key") == "repository" + and (prop.get("operator") or "exact") in LITERAL_REPOSITORY_OPERATORS + ): value = prop.get("value") values = value if isinstance(value, list) else [value] renamed.update({item: item.lower() for item in values if isinstance(item, str) and item != item.lower()}) From b21f28179eaecfa18f0a32bce8d314c6837908ab Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:49:21 +0000 Subject: [PATCH 4/9] fix(integrations): key github repository options on the lowercased name The API stores a repository filter lowercased, while GitHub reports `full_name` in the owner's casing. An option keyed with GitHub's casing therefore matches no saved value, so LemonInputSelect offers the saved repository as a custom value beside the real one and drops that repository's rich label. Only the qualified-name mode is lowercased. Short-name callers keep the casing they store today, and the other qualified-name caller already lowercases these keys itself. Generated-By: PostHog Desktop Task-Id: 7be92111-1bae-4905-8bec-3322d8160cac --- .../GitHubIntegrationHelpers.test.tsx | 19 ++++++++++--------- .../integrations/GitHubIntegrationHelpers.tsx | 10 +++++++++- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/integrations/GitHubIntegrationHelpers.test.tsx b/frontend/src/lib/integrations/GitHubIntegrationHelpers.test.tsx index 656fb667bcfb..339dc17c191e 100644 --- a/frontend/src/lib/integrations/GitHubIntegrationHelpers.test.tsx +++ b/frontend/src/lib/integrations/GitHubIntegrationHelpers.test.tsx @@ -8,7 +8,8 @@ import { initKeaTests } from '~/test/init' import { useRepositories } from './GitHubIntegrationHelpers' -const REPOS = [{ id: 1, name: 'posthog', full_name: 'PostHog/posthog', pushed_at: '2026-01-02T00:00:00Z' }] +// A mixed-case short name, so a key that lowercases every mode fails the default-mode test below. +const REPOS = [{ id: 1, name: 'HouseWatch', full_name: 'PostHog/HouseWatch', pushed_at: '2026-01-02T00:00:00Z' }] function OptionKeysProbe({ valueKey }: { valueKey?: 'name' | 'full_name' }): JSX.Element { const { options, loading } = useRepositories(1, { valueKey }) @@ -32,10 +33,12 @@ describe('useRepositories', () => { cleanup() }) - // The emitted `$github_event_received` event carries the qualified name (owner/repo). A picker - // that keys its option on the short name instead compiles a repository filter that no - // delivery can ever match - the bug this option exists to avoid. - it('keys options on the qualified name when valueKey is full_name', async () => { + // The emitted `$github_event_received` event carries the qualified name (owner/repo), lowercased + // to match the repository filter the API stores. A picker that keys its option on the short name + // compiles a repository filter that no delivery can ever match. One that keeps GitHub's casing + // leaves the stored value matching no option, so the picker offers it as a custom value beside + // the real repository and drops that repository's rich label. + it('keys options on the lowercased qualified name when valueKey is full_name', async () => { render( @@ -43,7 +46,7 @@ describe('useRepositories', () => { ) await act(() => new Promise((r) => setTimeout(r, 500))) - expect(screen.getByTestId('option-keys')).toHaveTextContent('PostHog/posthog') + expect(screen.getByTestId('option-keys').textContent).toBe('posthog/housewatch') }) it('keys options on the short name by default, unchanged for existing callers', async () => { @@ -54,8 +57,6 @@ describe('useRepositories', () => { ) await act(() => new Promise((r) => setTimeout(r, 500))) - const content = screen.getByTestId('option-keys') - expect(content).toHaveTextContent('posthog') - expect(content.textContent).not.toBe('PostHog/posthog') + expect(screen.getByTestId('option-keys').textContent).toBe('HouseWatch') }) }) diff --git a/frontend/src/lib/integrations/GitHubIntegrationHelpers.tsx b/frontend/src/lib/integrations/GitHubIntegrationHelpers.tsx index bb6b6466ba49..46d5a3616c84 100644 --- a/frontend/src/lib/integrations/GitHubIntegrationHelpers.tsx +++ b/frontend/src/lib/integrations/GitHubIntegrationHelpers.tsx @@ -201,7 +201,15 @@ export function useRepositories( // Most-recently-pushed first so the repo the user is working in floats to the top. [...repositories] .sort((a, b) => pushedAtMs(b.pushed_at) - pushedAtMs(a.pushed_at)) - .map((r) => ({ key: r[valueKey], label: r.full_name, labelComponent: })), + // A qualified-name key is lowercased because the stored value is. The API lowercases + // a repository filter on save, while GitHub reports `full_name` in the owner's + // casing. Compared as-is, the stored value matches no option, so LemonInputSelect + // shows it as a custom value beside the real repository and drops the rich label. + .map((r) => ({ + key: valueKey === 'full_name' ? r[valueKey].toLowerCase() : r[valueKey], + label: r.full_name, + labelComponent: , + })), [repositories, valueKey] ) From c47f1a5dd8a7bbd2256dde5b7f702df7dfef7043 Mon Sep 17 00:00:00 2001 From: Harley Alexander Date: Thu, 17 Sep 2026 16:07:12 +0100 Subject: [PATCH 5/9] fix(workflows): rewrite only repository operands and skip rows edited during the backfill --- ...026_lowercase_github_repository_filters.py | 45 +++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/products/workflows/backend/migrations/0026_lowercase_github_repository_filters.py b/products/workflows/backend/migrations/0026_lowercase_github_repository_filters.py index 16b609fc6480..38f86df9dd8e 100644 --- a/products/workflows/backend/migrations/0026_lowercase_github_repository_filters.py +++ b/products/workflows/backend/migrations/0026_lowercase_github_repository_filters.py @@ -1,4 +1,5 @@ from django.db import migrations +from django.utils import timezone BATCH_SIZE = 1000 @@ -34,9 +35,8 @@ def _lowercased_filters(filters: object) -> dict | None: The compiler ANDs the conditions on an event entry with the global ones, so a repository filter written on the entry decides whether the trigger fires too and needs the same rewrite. - The compiled bytecode holds the same strings as constants, so they are rewritten too rather - than recompiled; a value string reused by another filter on the same trigger is rare enough - to accept. + The compiled bytecode is rewritten in place rather than recompiled, touching only the operands + of a repository condition so a filter on another key that reuses the same string keeps it. """ if not isinstance(filters, dict): return None @@ -62,10 +62,31 @@ def _lowercased_filters(filters: object) -> dict | None: return None bytecode = filters.get("bytecode") if isinstance(bytecode, list): - rewritten["bytecode"] = [renamed.get(op, op) if isinstance(op, str) else op for op in bytecode] + rewritten["bytecode"] = _lowercased_bytecode(bytecode) return rewritten +# The operand(s) of a property condition sit right before the field access that reads +# `properties.repository`: `32, ` for one value, or `32, , 32, , 44, ` for a list. +REPOSITORY_FIELD_ACCESS = [32, "repository", 32, "properties", 1, 2] + + +def _lowercased_bytecode(bytecode: list) -> list: + result = list(bytecode) + width = len(REPOSITORY_FIELD_ACCESS) + for j in range(2, len(result) - width + 1): + if result[j : j + width] != REPOSITORY_FIELD_ACCESS: + continue + if result[j - 2] == 44 and isinstance(result[j - 1], int): + operands = [j - 1 - 2 * result[j - 1] + 2 * k for k in range(result[j - 1])] + else: + operands = [j - 1] + for i in operands: + if i > 0 and result[i - 1] == 32 and isinstance(result[i], str): + result[i] = result[i].lower() + return result + + def _lowercased_config(config: object) -> dict | None: if not isinstance(config, dict) or config.get("type") != "internal-event": return None @@ -96,28 +117,24 @@ def lowercase_github_repository_filters(apps, schema_editor): so only the live trigger and its action need rewriting here.""" HogFlow = apps.get_model("workflows", "HogFlow") db_alias = schema_editor.connection.alias - flows_to_update = [] for row in ( HogFlow.objects.using(db_alias) .filter(trigger__type="internal-event") .order_by("pk") - .values("pk", "trigger", "actions") + .values("pk", "updated_at", "trigger", "actions") .iterator(chunk_size=BATCH_SIZE) ): trigger = _lowercased_config(row["trigger"]) actions = _lowercased_actions(row["actions"]) if trigger is None and actions is None: continue - flows_to_update.append( - HogFlow(pk=row["pk"], trigger=trigger or row["trigger"], actions=actions or row["actions"]) + # A row saved since the read went through the serializer, which lowercases on its own, so + # skipping it is correct and the stale snapshot never overwrites that edit. Bumping + # updated_at makes an editor tab opened before the rewrite fail its stale-write check. + HogFlow.objects.using(db_alias).filter(pk=row["pk"], updated_at=row["updated_at"]).update( + trigger=trigger or row["trigger"], actions=actions or row["actions"], updated_at=timezone.now() ) - if len(flows_to_update) == BATCH_SIZE: - HogFlow.objects.using(db_alias).bulk_update(flows_to_update, ["trigger", "actions"], batch_size=BATCH_SIZE) - flows_to_update = [] - - if flows_to_update: - HogFlow.objects.using(db_alias).bulk_update(flows_to_update, ["trigger", "actions"], batch_size=BATCH_SIZE) class Migration(migrations.Migration): From d659be1fb6501a770e0533afa563ffc09769e74d Mon Sep 17 00:00:00 2001 From: Harley Alexander Date: Mon, 28 Sep 2026 11:10:13 +0100 Subject: [PATCH 6/9] fix(workflows): leave regex and ilike repository patterns alone in the backfill --- ...027_lowercase_github_repository_filters.py | 6 ++++-- .../test/test_github_trigger_filters.py | 19 +++++++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/products/workflows/backend/migrations/0027_lowercase_github_repository_filters.py b/products/workflows/backend/migrations/0027_lowercase_github_repository_filters.py index e0fba2a17476..8cdd9ebf2088 100644 --- a/products/workflows/backend/migrations/0027_lowercase_github_repository_filters.py +++ b/products/workflows/backend/migrations/0027_lowercase_github_repository_filters.py @@ -69,13 +69,15 @@ def _lowercased_filters(filters: object) -> dict | None: # The operand(s) of a property condition sit right before the field access that reads # `properties.repository`: `32, ` for one value, or `32, , 32, , 44, ` for a list. REPOSITORY_FIELD_ACCESS = [32, "repository", 32, "properties", 1, 2] +# EQ, NOT_EQ, IN, NOT_IN: what `exact` and `is_not` compile to. A regex or ilike pattern sits in the same operand slot. +LITERAL_COMPARE_OPCODES = frozenset({11, 12, 21, 22}) def _lowercased_bytecode(bytecode: list) -> list: result = list(bytecode) width = len(REPOSITORY_FIELD_ACCESS) - for j in range(2, len(result) - width + 1): - if result[j : j + width] != REPOSITORY_FIELD_ACCESS: + for j in range(2, len(result) - width): + if result[j : j + width] != REPOSITORY_FIELD_ACCESS or result[j + width] not in LITERAL_COMPARE_OPCODES: continue if result[j - 2] == 44 and isinstance(result[j - 1], int): operands = [j - 1 - 2 * result[j - 1] + 2 * k for k in range(result[j - 1])] diff --git a/products/workflows/backend/test/test_github_trigger_filters.py b/products/workflows/backend/test/test_github_trigger_filters.py index b65b1a14fda2..b5c288543ac5 100644 --- a/products/workflows/backend/test/test_github_trigger_filters.py +++ b/products/workflows/backend/test/test_github_trigger_filters.py @@ -1,4 +1,5 @@ import json +import importlib from typing import Any from posthog.test.base import APIBaseTest, ClickhouseTestMixin @@ -40,9 +41,12 @@ class TestGithubTriggerFilters(ClickhouseTestMixin, APIBaseTest): """The trigger editor writes property filters, the engine runs their bytecode. These check the two actually agree, which is what the editor's own round-trip tests can't see.""" - def _matches(self, properties: list[dict], globals: dict | None = None) -> bool: + def _bytecode(self, properties: list[dict]) -> list: expr = hog_function_filters_to_expr(filters={"properties": properties}, team=self.team, actions={}) - bytecode = json.loads(json.dumps(create_bytecode(expr).bytecode)) + return json.loads(json.dumps(create_bytecode(expr).bytecode)) + + def _matches(self, properties: list[dict], globals: dict | None = None) -> bool: + bytecode = self._bytecode(properties) return execute_bytecode(bytecode, globals or GITHUB_EVENT_GLOBALS).result is True @parameterized.expand( @@ -112,3 +116,14 @@ def test_filters_combine_with_and(self): ] assert self._matches(properties) assert not self._matches(properties, _event(actor_access="read")) + + def test_backfill_lowercases_literal_repositories_and_keeps_patterns(self): + migration = importlib.import_module( + "products.workflows.backend.migrations.0027_lowercase_github_repository_filters" + ) + bytecode = self._bytecode( + [_prop("repository", ["PostHog/Posthog"], "exact"), _prop("repository", "^PostHog/", "regex")] + ) + rewritten = migration._lowercased_bytecode(bytecode) + assert "posthog/posthog" in rewritten and "PostHog/Posthog" not in rewritten + assert "^PostHog/" in rewritten From 0c132196226c3c26a3ae482c98ca89e39ada3863 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:43:31 +0000 Subject: [PATCH 7/9] fix(workflows): lowercase github repository filters in staged drafts too A test run executes the staged draft's bytecode as stored, and only publish sends the draft through the serializer. A draft staged before the lowercasing change kept its mixed-case filter and bytecode, so a test run could miss a delivery that the published flow would match. The backfill now also rewrites the draft's trigger and trigger action, guarded by draft_updated_at. The docstring now states that restoring a revision copies its content without the serializer. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 7589aa5f-df0f-42e9-9615-e4cab16b01e4 --- ...027_lowercase_github_repository_filters.py | 49 ++++++++++++++----- .../test/test_github_trigger_filters.py | 35 +++++++++++++ 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/products/workflows/backend/migrations/0027_lowercase_github_repository_filters.py b/products/workflows/backend/migrations/0027_lowercase_github_repository_filters.py index 8cdd9ebf2088..b74c665f5fd1 100644 --- a/products/workflows/backend/migrations/0027_lowercase_github_repository_filters.py +++ b/products/workflows/backend/migrations/0027_lowercase_github_repository_filters.py @@ -1,4 +1,5 @@ from django.db import migrations +from django.db.models import Q from django.utils import timezone BATCH_SIZE = 1000 @@ -114,29 +115,53 @@ def _lowercased_actions(actions: object) -> list | None: return result if changed else None +def _lowercased_draft(draft: object) -> dict | None: + if not isinstance(draft, dict): + return None + trigger = _lowercased_config(draft.get("trigger")) + actions = _lowercased_actions(draft.get("actions")) + if trigger is None and actions is None: + return None + rewritten = {**draft} + if trigger is not None: + rewritten["trigger"] = trigger + if actions is not None: + rewritten["actions"] = actions + return rewritten + + def lowercase_github_repository_filters(apps, schema_editor): - """Publishing a draft or restoring a revision re-runs the serializer, which lowercases on its own, - so only the live trigger and its action need rewriting here.""" + """Rewrite the live trigger and the staged draft. A test run executes the draft's bytecode as + stored, and only publish sends the draft through the serializer, which lowercases on its own. + + Restoring a revision copies its content into the draft without the serializer, so a revision + taken before this change keeps its casing until that draft is published.""" HogFlow = apps.get_model("workflows", "HogFlow") db_alias = schema_editor.connection.alias for row in ( HogFlow.objects.using(db_alias) - .filter(trigger__type="internal-event") + .filter(Q(trigger__type="internal-event") | Q(draft__trigger__type="internal-event")) .order_by("pk") - .values("pk", "updated_at", "trigger", "actions") + .values("pk", "updated_at", "trigger", "actions", "draft", "draft_updated_at") .iterator(chunk_size=BATCH_SIZE) ): trigger = _lowercased_config(row["trigger"]) actions = _lowercased_actions(row["actions"]) - if trigger is None and actions is None: - continue - # A row saved since the read went through the serializer, which lowercases on its own, so - # skipping it is correct and the stale snapshot never overwrites that edit. Bumping - # updated_at makes an editor tab opened before the rewrite fail its stale-write check. - HogFlow.objects.using(db_alias).filter(pk=row["pk"], updated_at=row["updated_at"]).update( - trigger=trigger or row["trigger"], actions=actions or row["actions"], updated_at=timezone.now() - ) + if trigger is not None or actions is not None: + # A row saved since the read went through the serializer, which lowercases on its own, so + # skipping it is correct and the stale snapshot never overwrites that edit. Bumping + # updated_at makes an editor tab opened before the rewrite fail its stale-write check. + HogFlow.objects.using(db_alias).filter(pk=row["pk"], updated_at=row["updated_at"]).update( + trigger=trigger or row["trigger"], actions=actions or row["actions"], updated_at=timezone.now() + ) + draft = _lowercased_draft(row["draft"]) + if draft is not None: + # A draft write sets draft_updated_at and leaves updated_at alone, and a publish or discard + # clears it, so the draft needs its own guard for the same reason. + HogFlow.objects.using(db_alias).filter(pk=row["pk"], draft_updated_at=row["draft_updated_at"]).update( + draft=draft, draft_updated_at=timezone.now() + ) class Migration(migrations.Migration): diff --git a/products/workflows/backend/test/test_github_trigger_filters.py b/products/workflows/backend/test/test_github_trigger_filters.py index b5c288543ac5..7a43567918b5 100644 --- a/products/workflows/backend/test/test_github_trigger_filters.py +++ b/products/workflows/backend/test/test_github_trigger_filters.py @@ -4,12 +4,18 @@ from posthog.test.base import APIBaseTest, ClickhouseTestMixin +from django.apps import apps +from django.db import connection +from django.utils import timezone + from parameterized import parameterized from posthog.hogql.compiler.bytecode import create_bytecode from posthog.cdp.filters import hog_function_filters_to_expr +from products.workflows.backend.models.hog_flow.hog_flow import HogFlow + from common.hogvm.python.execute import execute_bytecode GITHUB_EVENT_GLOBALS: dict[str, Any] = { @@ -127,3 +133,32 @@ def test_backfill_lowercases_literal_repositories_and_keeps_patterns(self): rewritten = migration._lowercased_bytecode(bytecode) assert "posthog/posthog" in rewritten and "PostHog/Posthog" not in rewritten assert "^PostHog/" in rewritten + + def test_backfill_lowercases_a_staged_draft(self): + migration = importlib.import_module( + "products.workflows.backend.migrations.0027_lowercase_github_repository_filters" + ) + properties = [_prop("repository", ["PostHog/posthog"], "exact")] + config = { + "type": "internal-event", + "filters": { + "events": [{"id": "$github_event_received", "type": "events"}], + "properties": properties, + "bytecode": self._bytecode(properties), + }, + } + flow = HogFlow.objects.create( + team=self.team, + name="GitHub trigger staged in a draft", + draft={"trigger": config, "actions": [{"id": "trigger_node", "type": "trigger", "config": config}]}, + draft_updated_at=timezone.now(), + ) + + with connection.schema_editor() as schema_editor: + migration.lowercase_github_repository_filters(apps, schema_editor) + + flow.refresh_from_db() + delivery = _event(repository="posthog/posthog") + for filters in (flow.draft["trigger"]["filters"], flow.draft["actions"][0]["config"]["filters"]): + assert filters["properties"][0]["value"] == ["posthog/posthog"] + assert execute_bytecode(filters["bytecode"], delivery).result is True From 3d9e476401e019e8a76f5d6dd1a274ae9028077d Mon Sep 17 00:00:00 2001 From: Harley Alexander Date: Mon, 28 Sep 2026 15:48:38 +0100 Subject: [PATCH 8/9] chore(workflows): narrow draft type in github trigger backfill test --- .flox/env/manifest.lock | 174 +++++++++--------- .../test/test_github_trigger_filters.py | 1 + 2 files changed, 88 insertions(+), 87 deletions(-) diff --git a/.flox/env/manifest.lock b/.flox/env/manifest.lock index 8bbbfa2a0622..94e522c64afc 100644 --- a/.flox/env/manifest.lock +++ b/.flox/env/manifest.lock @@ -309,6 +309,93 @@ "group": "cmake", "priority": 5 }, + { + "attr_path": "depot-cli", + "broken": false, + "derivation": "/nix/store/8hnwcq7wrlzwizkrxjhib7bx82llp5xi-depot-cli-2.102.9.drv", + "description": "Official CLI for the Depot Docker image builder", + "install_id": "depot-cli", + "license": "MIT", + "locked_url": "https://github.com/flox/nixpkgs?rev=6774f7bc253789b113a4f39285dc0fa100abeacc", + "name": "depot-cli-2.102.9", + "pname": "depot-cli", + "rev": "6774f7bc253789b113a4f39285dc0fa100abeacc", + "rev_count": 1077996, + "rev_date": "2026-09-22T03:11:10Z", + "scrape_date": "2026-09-23T04:58:05.803991Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "2.102.9", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/1bi1y13kr74rlmj8j4vslq993j1vdm5c-depot-cli-2.102.9" + }, + "system": "aarch64-darwin", + "group": "depot", + "priority": 5 + }, + { + "attr_path": "depot-cli", + "broken": false, + "derivation": "/nix/store/zkw5dmf93pfr1wm7f2622g7lyw8yz10w-depot-cli-2.102.9.drv", + "description": "Official CLI for the Depot Docker image builder", + "install_id": "depot-cli", + "license": "MIT", + "locked_url": "https://github.com/flox/nixpkgs?rev=6774f7bc253789b113a4f39285dc0fa100abeacc", + "name": "depot-cli-2.102.9", + "pname": "depot-cli", + "rev": "6774f7bc253789b113a4f39285dc0fa100abeacc", + "rev_count": 1077996, + "rev_date": "2026-09-22T03:11:10Z", + "scrape_date": "2026-09-23T05:29:39.537297Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "2.102.9", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/ld8k0nmfl02ysjkkr9l2m5i3hy893sn3-depot-cli-2.102.9" + }, + "system": "aarch64-linux", + "group": "depot", + "priority": 5 + }, + { + "attr_path": "depot-cli", + "broken": false, + "derivation": "/nix/store/vjh6m3hgsafr06qx6ajfmva818rfwq2f-depot-cli-2.102.9.drv", + "description": "Official CLI for the Depot Docker image builder", + "install_id": "depot-cli", + "license": "MIT", + "locked_url": "https://github.com/flox/nixpkgs?rev=6774f7bc253789b113a4f39285dc0fa100abeacc", + "name": "depot-cli-2.102.9", + "pname": "depot-cli", + "rev": "6774f7bc253789b113a4f39285dc0fa100abeacc", + "rev_count": 1077996, + "rev_date": "2026-09-22T03:11:10Z", + "scrape_date": "2026-09-23T06:06:17.967455Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "2.102.9", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/y9pf0adrvbwj3ki0ab9nch76p9y1d05v-depot-cli-2.102.9" + }, + "system": "x86_64-linux", + "group": "depot", + "priority": 5 + }, { "attr_path": "emscripten", "broken": false, @@ -3511,93 +3598,6 @@ "system": "x86_64-linux", "group": "uv", "priority": 5 - }, - { - "attr_path": "depot-cli", - "broken": false, - "derivation": "/nix/store/8hnwcq7wrlzwizkrxjhib7bx82llp5xi-depot-cli-2.102.9.drv", - "description": "Official CLI for the Depot Docker image builder", - "install_id": "depot-cli", - "license": "MIT", - "locked_url": "https://github.com/flox/nixpkgs?rev=6774f7bc253789b113a4f39285dc0fa100abeacc", - "name": "depot-cli-2.102.9", - "pname": "depot-cli", - "rev": "6774f7bc253789b113a4f39285dc0fa100abeacc", - "rev_count": 1077996, - "rev_date": "2026-09-22T03:11:10Z", - "scrape_date": "2026-09-23T04:58:05.803991Z", - "stabilities": [ - "unstable" - ], - "unfree": false, - "version": "2.102.9", - "outputs_to_install": [ - "out" - ], - "outputs": { - "out": "/nix/store/1bi1y13kr74rlmj8j4vslq993j1vdm5c-depot-cli-2.102.9" - }, - "system": "aarch64-darwin", - "group": "depot", - "priority": 5 - }, - { - "attr_path": "depot-cli", - "broken": false, - "derivation": "/nix/store/zkw5dmf93pfr1wm7f2622g7lyw8yz10w-depot-cli-2.102.9.drv", - "description": "Official CLI for the Depot Docker image builder", - "install_id": "depot-cli", - "license": "MIT", - "locked_url": "https://github.com/flox/nixpkgs?rev=6774f7bc253789b113a4f39285dc0fa100abeacc", - "name": "depot-cli-2.102.9", - "pname": "depot-cli", - "rev": "6774f7bc253789b113a4f39285dc0fa100abeacc", - "rev_count": 1077996, - "rev_date": "2026-09-22T03:11:10Z", - "scrape_date": "2026-09-23T05:29:39.537297Z", - "stabilities": [ - "unstable" - ], - "unfree": false, - "version": "2.102.9", - "outputs_to_install": [ - "out" - ], - "outputs": { - "out": "/nix/store/ld8k0nmfl02ysjkkr9l2m5i3hy893sn3-depot-cli-2.102.9" - }, - "system": "aarch64-linux", - "group": "depot", - "priority": 5 - }, - { - "attr_path": "depot-cli", - "broken": false, - "derivation": "/nix/store/vjh6m3hgsafr06qx6ajfmva818rfwq2f-depot-cli-2.102.9.drv", - "description": "Official CLI for the Depot Docker image builder", - "install_id": "depot-cli", - "license": "MIT", - "locked_url": "https://github.com/flox/nixpkgs?rev=6774f7bc253789b113a4f39285dc0fa100abeacc", - "name": "depot-cli-2.102.9", - "pname": "depot-cli", - "rev": "6774f7bc253789b113a4f39285dc0fa100abeacc", - "rev_count": 1077996, - "rev_date": "2026-09-22T03:11:10Z", - "scrape_date": "2026-09-23T06:06:17.967455Z", - "stabilities": [ - "unstable" - ], - "unfree": false, - "version": "2.102.9", - "outputs_to_install": [ - "out" - ], - "outputs": { - "out": "/nix/store/y9pf0adrvbwj3ki0ab9nch76p9y1d05v-depot-cli-2.102.9" - }, - "system": "x86_64-linux", - "group": "depot", - "priority": 5 } ] } diff --git a/products/workflows/backend/test/test_github_trigger_filters.py b/products/workflows/backend/test/test_github_trigger_filters.py index 7a43567918b5..c60463566a8f 100644 --- a/products/workflows/backend/test/test_github_trigger_filters.py +++ b/products/workflows/backend/test/test_github_trigger_filters.py @@ -158,6 +158,7 @@ def test_backfill_lowercases_a_staged_draft(self): migration.lowercase_github_repository_filters(apps, schema_editor) flow.refresh_from_db() + assert flow.draft is not None delivery = _event(repository="posthog/posthog") for filters in (flow.draft["trigger"]["filters"], flow.draft["actions"][0]["config"]["filters"]): assert filters["properties"][0]["value"] == ["posthog/posthog"] From fbc04f71f5203311170426811e9fee7f116989d4 Mon Sep 17 00:00:00 2001 From: Harley Alexander Date: Tue, 29 Sep 2026 09:46:37 +0100 Subject: [PATCH 9/9] chore(workflows): drop github repository casing backfill --- ...027_lowercase_github_repository_filters.py | 174 ------------------ .../backend/migrations/max_migration.txt | 2 +- .../test/test_github_trigger_filters.py | 55 +----- 3 files changed, 3 insertions(+), 228 deletions(-) delete mode 100644 products/workflows/backend/migrations/0027_lowercase_github_repository_filters.py diff --git a/products/workflows/backend/migrations/0027_lowercase_github_repository_filters.py b/products/workflows/backend/migrations/0027_lowercase_github_repository_filters.py deleted file mode 100644 index b74c665f5fd1..000000000000 --- a/products/workflows/backend/migrations/0027_lowercase_github_repository_filters.py +++ /dev/null @@ -1,174 +0,0 @@ -from django.db import migrations -from django.db.models import Q -from django.utils import timezone - -BATCH_SIZE = 1000 - -GITHUB_EVENT_RECEIVED_EVENT = "$github_event_received" - -# Lowercasing only preserves meaning for an operator that compares the value as a literal string. -# A pattern changes what it matches, or stops compiling, and a presence operator carries the -# operator string rather than a repository name. A missing operator compiles as exact. -LITERAL_REPOSITORY_OPERATORS = frozenset({"exact", "is_not"}) - - -def _lowercased_properties(properties: list, renamed: dict[str, str]) -> list: - """The properties with every `repository` value lowercased, recording each rewrite in `renamed`.""" - result = [] - for prop in properties: - if ( - isinstance(prop, dict) - and prop.get("key") == "repository" - and (prop.get("operator") or "exact") in LITERAL_REPOSITORY_OPERATORS - ): - value = prop.get("value") - values = value if isinstance(value, list) else [value] - renamed.update({item: item.lower() for item in values if isinstance(item, str) and item != item.lower()}) - lowered = [item.lower() if isinstance(item, str) else item for item in values] - prop = {**prop, "value": lowered if isinstance(value, list) else lowered[0]} - result.append(prop) - return result - - -def _lowercased_filters(filters: object) -> dict | None: - """The filters with every `repository` value lowercased, or None when nothing changes. - - The compiler ANDs the conditions on an event entry with the global ones, so a repository - filter written on the entry decides whether the trigger fires too and needs the same rewrite. - - The compiled bytecode is rewritten in place rather than recompiled, touching only the operands - of a repository condition so a filter on another key that reuses the same string keeps it. - """ - if not isinstance(filters, dict): - return None - events = filters.get("events") - if not isinstance(events, list) or not any( - isinstance(event, dict) and event.get("id") == GITHUB_EVENT_RECEIVED_EVENT for event in events - ): - return None - renamed: dict[str, str] = {} - rewritten: dict = {**filters} - properties = filters.get("properties") - if isinstance(properties, list): - rewritten["properties"] = _lowercased_properties(properties, renamed) - rewritten["events"] = [ - {**event, "properties": _lowercased_properties(event["properties"], renamed)} - if isinstance(event, dict) - and event.get("id") == GITHUB_EVENT_RECEIVED_EVENT - and isinstance(event.get("properties"), list) - else event - for event in events - ] - if not renamed: - return None - bytecode = filters.get("bytecode") - if isinstance(bytecode, list): - rewritten["bytecode"] = _lowercased_bytecode(bytecode) - return rewritten - - -# The operand(s) of a property condition sit right before the field access that reads -# `properties.repository`: `32, ` for one value, or `32, , 32, , 44, ` for a list. -REPOSITORY_FIELD_ACCESS = [32, "repository", 32, "properties", 1, 2] -# EQ, NOT_EQ, IN, NOT_IN: what `exact` and `is_not` compile to. A regex or ilike pattern sits in the same operand slot. -LITERAL_COMPARE_OPCODES = frozenset({11, 12, 21, 22}) - - -def _lowercased_bytecode(bytecode: list) -> list: - result = list(bytecode) - width = len(REPOSITORY_FIELD_ACCESS) - for j in range(2, len(result) - width): - if result[j : j + width] != REPOSITORY_FIELD_ACCESS or result[j + width] not in LITERAL_COMPARE_OPCODES: - continue - if result[j - 2] == 44 and isinstance(result[j - 1], int): - operands = [j - 1 - 2 * result[j - 1] + 2 * k for k in range(result[j - 1])] - else: - operands = [j - 1] - for i in operands: - if i > 0 and result[i - 1] == 32 and isinstance(result[i], str): - result[i] = result[i].lower() - return result - - -def _lowercased_config(config: object) -> dict | None: - if not isinstance(config, dict) or config.get("type") != "internal-event": - return None - filters = _lowercased_filters(config.get("filters")) - return None if filters is None else {**config, "filters": filters} - - -def _lowercased_actions(actions: object) -> list | None: - if not isinstance(actions, list): - return None - changed = False - result = [] - for action in actions: - config = ( - _lowercased_config(action.get("config")) - if isinstance(action, dict) and action.get("type") == "trigger" - else None - ) - if config is not None: - action = {**action, "config": config} - changed = True - result.append(action) - return result if changed else None - - -def _lowercased_draft(draft: object) -> dict | None: - if not isinstance(draft, dict): - return None - trigger = _lowercased_config(draft.get("trigger")) - actions = _lowercased_actions(draft.get("actions")) - if trigger is None and actions is None: - return None - rewritten = {**draft} - if trigger is not None: - rewritten["trigger"] = trigger - if actions is not None: - rewritten["actions"] = actions - return rewritten - - -def lowercase_github_repository_filters(apps, schema_editor): - """Rewrite the live trigger and the staged draft. A test run executes the draft's bytecode as - stored, and only publish sends the draft through the serializer, which lowercases on its own. - - Restoring a revision copies its content into the draft without the serializer, so a revision - taken before this change keeps its casing until that draft is published.""" - HogFlow = apps.get_model("workflows", "HogFlow") - db_alias = schema_editor.connection.alias - - for row in ( - HogFlow.objects.using(db_alias) - .filter(Q(trigger__type="internal-event") | Q(draft__trigger__type="internal-event")) - .order_by("pk") - .values("pk", "updated_at", "trigger", "actions", "draft", "draft_updated_at") - .iterator(chunk_size=BATCH_SIZE) - ): - trigger = _lowercased_config(row["trigger"]) - actions = _lowercased_actions(row["actions"]) - if trigger is not None or actions is not None: - # A row saved since the read went through the serializer, which lowercases on its own, so - # skipping it is correct and the stale snapshot never overwrites that edit. Bumping - # updated_at makes an editor tab opened before the rewrite fail its stale-write check. - HogFlow.objects.using(db_alias).filter(pk=row["pk"], updated_at=row["updated_at"]).update( - trigger=trigger or row["trigger"], actions=actions or row["actions"], updated_at=timezone.now() - ) - draft = _lowercased_draft(row["draft"]) - if draft is not None: - # A draft write sets draft_updated_at and leaves updated_at alone, and a publish or discard - # clears it, so the draft needs its own guard for the same reason. - HogFlow.objects.using(db_alias).filter(pk=row["pk"], draft_updated_at=row["draft_updated_at"]).update( - draft=draft, draft_updated_at=timezone.now() - ) - - -class Migration(migrations.Migration): - dependencies = [ - ("workflows", "0026_workflowproposal"), - ] - - operations = [ - migrations.RunPython(lowercase_github_repository_filters, migrations.RunPython.noop), - ] diff --git a/products/workflows/backend/migrations/max_migration.txt b/products/workflows/backend/migrations/max_migration.txt index 2ce2f7ad20d6..e300e8f6ca1d 100644 --- a/products/workflows/backend/migrations/max_migration.txt +++ b/products/workflows/backend/migrations/max_migration.txt @@ -1 +1 @@ -0027_lowercase_github_repository_filters +0026_workflowproposal diff --git a/products/workflows/backend/test/test_github_trigger_filters.py b/products/workflows/backend/test/test_github_trigger_filters.py index c60463566a8f..b65b1a14fda2 100644 --- a/products/workflows/backend/test/test_github_trigger_filters.py +++ b/products/workflows/backend/test/test_github_trigger_filters.py @@ -1,21 +1,14 @@ import json -import importlib from typing import Any from posthog.test.base import APIBaseTest, ClickhouseTestMixin -from django.apps import apps -from django.db import connection -from django.utils import timezone - from parameterized import parameterized from posthog.hogql.compiler.bytecode import create_bytecode from posthog.cdp.filters import hog_function_filters_to_expr -from products.workflows.backend.models.hog_flow.hog_flow import HogFlow - from common.hogvm.python.execute import execute_bytecode GITHUB_EVENT_GLOBALS: dict[str, Any] = { @@ -47,12 +40,9 @@ class TestGithubTriggerFilters(ClickhouseTestMixin, APIBaseTest): """The trigger editor writes property filters, the engine runs their bytecode. These check the two actually agree, which is what the editor's own round-trip tests can't see.""" - def _bytecode(self, properties: list[dict]) -> list: - expr = hog_function_filters_to_expr(filters={"properties": properties}, team=self.team, actions={}) - return json.loads(json.dumps(create_bytecode(expr).bytecode)) - def _matches(self, properties: list[dict], globals: dict | None = None) -> bool: - bytecode = self._bytecode(properties) + expr = hog_function_filters_to_expr(filters={"properties": properties}, team=self.team, actions={}) + bytecode = json.loads(json.dumps(create_bytecode(expr).bytecode)) return execute_bytecode(bytecode, globals or GITHUB_EVENT_GLOBALS).result is True @parameterized.expand( @@ -122,44 +112,3 @@ def test_filters_combine_with_and(self): ] assert self._matches(properties) assert not self._matches(properties, _event(actor_access="read")) - - def test_backfill_lowercases_literal_repositories_and_keeps_patterns(self): - migration = importlib.import_module( - "products.workflows.backend.migrations.0027_lowercase_github_repository_filters" - ) - bytecode = self._bytecode( - [_prop("repository", ["PostHog/Posthog"], "exact"), _prop("repository", "^PostHog/", "regex")] - ) - rewritten = migration._lowercased_bytecode(bytecode) - assert "posthog/posthog" in rewritten and "PostHog/Posthog" not in rewritten - assert "^PostHog/" in rewritten - - def test_backfill_lowercases_a_staged_draft(self): - migration = importlib.import_module( - "products.workflows.backend.migrations.0027_lowercase_github_repository_filters" - ) - properties = [_prop("repository", ["PostHog/posthog"], "exact")] - config = { - "type": "internal-event", - "filters": { - "events": [{"id": "$github_event_received", "type": "events"}], - "properties": properties, - "bytecode": self._bytecode(properties), - }, - } - flow = HogFlow.objects.create( - team=self.team, - name="GitHub trigger staged in a draft", - draft={"trigger": config, "actions": [{"id": "trigger_node", "type": "trigger", "config": config}]}, - draft_updated_at=timezone.now(), - ) - - with connection.schema_editor() as schema_editor: - migration.lowercase_github_repository_filters(apps, schema_editor) - - flow.refresh_from_db() - assert flow.draft is not None - delivery = _event(repository="posthog/posthog") - for filters in (flow.draft["trigger"]["filters"], flow.draft["actions"][0]["config"]["filters"]): - assert filters["properties"][0]["value"] == ["posthog/posthog"] - assert execute_bytecode(filters["bytecode"], delivery).result is True