From 70ec2945c1ee8a4459a5553e571b9975b4a7b7fc Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 21 Apr 2026 23:04:36 +0100 Subject: [PATCH 1/2] fix(GitLab): match issues whose webhook URL uses the ``work_items`` path GitLab delivers issue webhooks with ``/-/work_items/`` URLs even when the feature was linked via the legacy ``/-/issues/`` form, and vice versa. ``apply_tag_for_event`` did an exact string match, so the receiver 200'd but the tag never flipped. Match by both URL variants when routing an issue event to its linked feature. beep boop --- api/integrations/gitlab/services.py | 14 +++++- .../features/test_gitlab_webhook.py | 45 +++++++++++++++++++ .../observability/_events-catalogue.md | 2 +- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/api/integrations/gitlab/services.py b/api/integrations/gitlab/services.py index 76a51ea608c9..aebc8168849c 100644 --- a/api/integrations/gitlab/services.py +++ b/api/integrations/gitlab/services.py @@ -200,6 +200,18 @@ def apply_initial_tag(resource: FeatureExternalResource) -> None: set_gitlab_tag(resource.feature, label) +def _issue_url_variants(url: str) -> list[str]: + """GitLab delivers issue webhooks with ``/-/work_items/`` URLs even when + the feature was linked via the legacy ``/-/issues/`` form (and vice + versa). Return both shapes so the matcher finds the stored resource. + """ + if "/-/issues/" in url: + return [url, url.replace("/-/issues/", "/-/work_items/", 1)] + if "/-/work_items/" in url: + return [url, url.replace("/-/work_items/", "/-/issues/", 1)] + return [url] + + def apply_tag_for_event( webhook: GitLabWebhook, payload: GitLabWebhookPayload, @@ -215,7 +227,7 @@ def apply_tag_for_event( if not ( feature := Feature.objects.filter( project=webhook.gitlab_configuration.project, - external_resources__url=resource_url, + external_resources__url__in=_issue_url_variants(resource_url), external_resources__type__in=GITLAB_RESOURCE_TYPES, ).first() ): diff --git a/api/tests/integration/features/test_gitlab_webhook.py b/api/tests/integration/features/test_gitlab_webhook.py index 4bf29b55fceb..c28864d52061 100644 --- a/api/tests/integration/features/test_gitlab_webhook.py +++ b/api/tests/integration/features/test_gitlab_webhook.py @@ -71,6 +71,51 @@ def _link( return _link +@pytest.mark.django_db() +@pytest.mark.parametrize( + "linked_url, payload_url", + [ + ( + "https://gitlab.example.com/testorg/testrepo/-/issues/42", + "https://gitlab.example.com/testorg/testrepo/-/work_items/42", + ), + ( + "https://gitlab.example.com/testorg/testrepo/-/work_items/42", + "https://gitlab.example.com/testorg/testrepo/-/issues/42", + ), + ], +) +def test_gitlab_webhook__issue_url_variants__still_matches_feature( + api_client: APIClient, + feature: int, + webhook_url: str, + link_feature: LinkFeatureFixture, + linked_url: str, + payload_url: str, +) -> None: + # Given — GitLab may deliver ``work_items`` URLs even when the link uses the + # legacy ``issues`` path (or vice versa). Both shapes refer to the same issue. + link_feature(linked_url, ResourceType.GITLAB_ISSUE, metadata={"state": "opened"}) + payload = { + "object_kind": "issue", + "object_attributes": {"url": payload_url, "state": "closed"}, + } + + # When + response = api_client.post( + webhook_url, + data=payload, + format="json", + HTTP_X_GITLAB_TOKEN=WEBHOOK_SECRET, + ) + + # Then + assert response.status_code == status.HTTP_200_OK + labels = set(Feature.objects.get(id=feature).tags.values_list("label", flat=True)) + assert GitLabTagLabel.ISSUE_CLOSED.value in labels + assert GitLabTagLabel.ISSUE_OPEN.value not in labels + + @pytest.mark.django_db() def test_gitlab_webhook__issue_close_event__switches_tag_to_issue_closed( api_client: APIClient, diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 81eb31771b3e..9089e82878ed 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -98,7 +98,7 @@ Attributes: ### `gitlab.feature.tagged` Logged at `info` from: - - `api/integrations/gitlab/services.py:229` + - `api/integrations/gitlab/services.py:241` Attributes: - `action` From 0ab369804ba44d6f0910a4a4b1fcb47810ec6111 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 21 Apr 2026 23:34:06 +0100 Subject: [PATCH 2/2] refactor(GitLab): move issue URL filter-value helper into mappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Response to review: lives better next to the other `map_*` helpers than inside `services.py`. Also drops a redundant `@pytest.mark.django_db()` from the parametrised test — the fixtures it uses already pull in `db`. beep boop --- api/integrations/gitlab/mappers.py | 15 +++++++++++++++ api/integrations/gitlab/services.py | 15 ++------------- .../integration/features/test_gitlab_webhook.py | 1 - .../observability/_events-catalogue.md | 10 +++++----- 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/api/integrations/gitlab/mappers.py b/api/integrations/gitlab/mappers.py index 7846af55b487..1567e0f3ce08 100644 --- a/api/integrations/gitlab/mappers.py +++ b/api/integrations/gitlab/mappers.py @@ -56,6 +56,21 @@ def map_gitlab_resource_to_tag_label( return None +def map_resource_url_to_filter_value(url: str) -> list[str]: + """Return a list of equivalent URL shapes for use as an ``__in`` filter + value when looking up a linked GitLab resource. + + GitLab delivers issue webhooks with ``/-/work_items/`` URLs even when + the feature was linked via the legacy ``/-/issues/`` form, and vice + versa. Both shapes refer to the same issue. + """ + if "/-/issues/" in url: + return [url, url.replace("/-/issues/", "/-/work_items/", 1)] + if "/-/work_items/" in url: + return [url, url.replace("/-/work_items/", "/-/issues/", 1)] + return [url] + + def map_gitlab_webhook_payload_to_tag_label( payload: GitLabWebhookPayload, ) -> GitLabTagLabel | None: diff --git a/api/integrations/gitlab/services.py b/api/integrations/gitlab/services.py index aebc8168849c..7e1d2353b3a6 100644 --- a/api/integrations/gitlab/services.py +++ b/api/integrations/gitlab/services.py @@ -27,6 +27,7 @@ from integrations.gitlab.mappers import ( map_gitlab_resource_to_tag_label, map_gitlab_webhook_payload_to_tag_label, + map_resource_url_to_filter_value, ) from integrations.gitlab.models import GitLabConfiguration, GitLabWebhook from integrations.gitlab.types import GitLabWebhookPayload @@ -200,18 +201,6 @@ def apply_initial_tag(resource: FeatureExternalResource) -> None: set_gitlab_tag(resource.feature, label) -def _issue_url_variants(url: str) -> list[str]: - """GitLab delivers issue webhooks with ``/-/work_items/`` URLs even when - the feature was linked via the legacy ``/-/issues/`` form (and vice - versa). Return both shapes so the matcher finds the stored resource. - """ - if "/-/issues/" in url: - return [url, url.replace("/-/issues/", "/-/work_items/", 1)] - if "/-/work_items/" in url: - return [url, url.replace("/-/work_items/", "/-/issues/", 1)] - return [url] - - def apply_tag_for_event( webhook: GitLabWebhook, payload: GitLabWebhookPayload, @@ -227,7 +216,7 @@ def apply_tag_for_event( if not ( feature := Feature.objects.filter( project=webhook.gitlab_configuration.project, - external_resources__url__in=_issue_url_variants(resource_url), + external_resources__url__in=map_resource_url_to_filter_value(resource_url), external_resources__type__in=GITLAB_RESOURCE_TYPES, ).first() ): diff --git a/api/tests/integration/features/test_gitlab_webhook.py b/api/tests/integration/features/test_gitlab_webhook.py index c28864d52061..ff39417b21be 100644 --- a/api/tests/integration/features/test_gitlab_webhook.py +++ b/api/tests/integration/features/test_gitlab_webhook.py @@ -71,7 +71,6 @@ def _link( return _link -@pytest.mark.django_db() @pytest.mark.parametrize( "linked_url, payload_url", [ diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 9089e82878ed..3d827d1d4fb2 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -98,7 +98,7 @@ Attributes: ### `gitlab.feature.tagged` Logged at `info` from: - - `api/integrations/gitlab/services.py:241` + - `api/integrations/gitlab/services.py:230` Attributes: - `action` @@ -111,7 +111,7 @@ Attributes: ### `gitlab.webhook.deregistered` Logged at `info` from: - - `api/integrations/gitlab/services.py:159` + - `api/integrations/gitlab/services.py:160` Attributes: - `gitlab.hook.id` @@ -122,7 +122,7 @@ Attributes: ### `gitlab.webhook.deregistration_failed` Logged at `warning` from: - - `api/integrations/gitlab/services.py:152` + - `api/integrations/gitlab/services.py:153` Attributes: - `exc_info` @@ -134,7 +134,7 @@ Attributes: ### `gitlab.webhook.registered` Logged at `info` from: - - `api/integrations/gitlab/services.py:112` + - `api/integrations/gitlab/services.py:113` Attributes: - `gitlab.hook.id` @@ -146,7 +146,7 @@ Attributes: ### `gitlab.webhook.registration_failed` Logged at `error` from: - - `api/integrations/gitlab/services.py:97` + - `api/integrations/gitlab/services.py:98` Attributes: - `exc_info`