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/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] ) diff --git a/products/workflows/backend/api/hog_flow.py b/products/workflows/backend/api/hog_flow.py index 4a3a5f663a96..9aaa40e488b1 100644 --- a/products/workflows/backend/api/hog_flow.py +++ b/products/workflows/backend/api/hog_flow.py @@ -915,6 +915,44 @@ 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() + elif isinstance(value, list): + 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 @@ -1674,6 +1712,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 cb430e23cc30..77006ce33b23 100644 --- a/products/workflows/backend/api/test/test_hog_flow.py +++ b/products/workflows/backend/api/test/test_hog_flow.py @@ -2910,6 +2910,56 @@ 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. 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", + "type": "trigger", + "config": { + "type": "internal-event", + "filters": { + "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"}, + # 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", + }, + ], + }, + }, + } + + 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_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: 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/test/test_github_workflow_events.py b/products/workflows/backend/test/test_github_workflow_events.py index 8b007ec1ad62..7a69ce07ccdc 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