diff --git a/api/app/settings/common.py b/api/app/settings/common.py index c75fae2fbf34..1b9b009c2e1c 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -154,6 +154,7 @@ "integrations.flagsmith", "integrations.launch_darkly", "integrations.github", + "integrations.gitlab", "integrations.grafana", # Rate limiting admin endpoints "axes", diff --git a/api/integrations/gitlab/__init__.py b/api/integrations/gitlab/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/gitlab/apps.py b/api/integrations/gitlab/apps.py new file mode 100644 index 000000000000..ad1b3f3221de --- /dev/null +++ b/api/integrations/gitlab/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class GitLabIntegrationConfig(AppConfig): + name = "integrations.gitlab" diff --git a/api/integrations/gitlab/client.py b/api/integrations/gitlab/client.py new file mode 100644 index 000000000000..39ca434813ff --- /dev/null +++ b/api/integrations/gitlab/client.py @@ -0,0 +1,271 @@ +import logging + +import requests + +from integrations.gitlab.constants import ( + GITLAB_API_CALLS_TIMEOUT, + GITLAB_FLAGSMITH_LABEL, + GITLAB_FLAGSMITH_LABEL_COLOUR, + GITLAB_FLAGSMITH_LABEL_DESCRIPTION, +) +from integrations.gitlab.dataclasses import ( + IssueQueryParams, + PaginatedQueryParams, + ProjectQueryParams, +) +from integrations.gitlab.types import ( + GitLabLabel, + GitLabMember, + GitLabNote, + GitLabProject, + GitLabResource, + GitLabResourceEndpoint, + GitLabResourceMetadata, + PaginatedResponse, +) + +logger = logging.getLogger(__name__) + + +def _build_request_headers(access_token: str) -> dict[str, str]: + return {"PRIVATE-TOKEN": access_token} + + +def _build_paginated_response( + results: list[GitLabProject] | list[GitLabResource] | list[GitLabMember], + response: requests.Response, + total_count: int | None = None, +) -> PaginatedResponse: + data: PaginatedResponse = {"results": results} + + current_page = int(response.headers.get("x-page", 1)) + total_pages = int(response.headers.get("x-total-pages", 1)) + + if current_page > 1: + data["previous"] = current_page - 1 + if current_page < total_pages: + data["next"] = current_page + 1 + + if total_count is not None: + data["total_count"] = total_count + + return data + + +def fetch_gitlab_projects( + instance_url: str, + access_token: str, + params: PaginatedQueryParams, +) -> PaginatedResponse: + url = f"{instance_url}/api/v4/projects" + response = requests.get( + url, + headers=_build_request_headers(access_token), + params={ + "membership": "true", + "per_page": str(params.page_size), + "page": str(params.page), + }, + timeout=GITLAB_API_CALLS_TIMEOUT, + ) + response.raise_for_status() + + results: list[GitLabProject] = [ + { + "id": project["id"], + "name": project["name"], + "path_with_namespace": project["path_with_namespace"], + } + for project in response.json() + ] + + total_count = int(response.headers.get("x-total", len(results))) + return _build_paginated_response(results, response, total_count) + + +def fetch_search_gitlab_resource( + resource_type: GitLabResourceEndpoint, + instance_url: str, + access_token: str, + params: IssueQueryParams, +) -> PaginatedResponse: + """Search issues or merge requests in a GitLab project.""" + url = f"{instance_url}/api/v4/projects/{params.gitlab_project_id}/{resource_type}" + query_params: dict[str, str | int] = { + "per_page": params.page_size, + "page": params.page, + } + if params.search_text: + query_params["search"] = params.search_text + if params.state: + query_params["state"] = params.state + if params.author: + query_params["author_username"] = params.author + if params.assignee: + query_params["assignee_username"] = params.assignee + + response = requests.get( + url, + headers=_build_request_headers(access_token), + params=query_params, + timeout=GITLAB_API_CALLS_TIMEOUT, + ) + response.raise_for_status() + + is_mr = resource_type == "merge_requests" + results: list[GitLabResource] = [ + { + "web_url": item["web_url"], + "id": item["id"], + "title": item["title"], + "iid": item["iid"], + "state": item["state"], + "merged": item.get("merged_at") is not None if is_mr else False, + "draft": item.get("draft", False) if is_mr else False, + } + for item in response.json() + ] + + total_count = int(response.headers.get("x-total", len(results))) + return _build_paginated_response(results, response, total_count) + + +def fetch_gitlab_project_members( + instance_url: str, + access_token: str, + params: ProjectQueryParams, +) -> PaginatedResponse: + url = f"{instance_url}/api/v4/projects/{params.gitlab_project_id}/members" + response = requests.get( + url, + headers=_build_request_headers(access_token), + params={"per_page": params.page_size, "page": params.page}, + timeout=GITLAB_API_CALLS_TIMEOUT, + ) + response.raise_for_status() + + results: list[GitLabMember] = [ + { + "username": member["username"], + "avatar_url": member["avatar_url"], + "name": member["name"], + } + for member in response.json() + ] + + return _build_paginated_response(results, response) + + +def create_gitlab_issue( + instance_url: str, + access_token: str, + gitlab_project_id: int, + title: str, + body: str, +) -> dict[str, object]: + url = f"{instance_url}/api/v4/projects/{gitlab_project_id}/issues" + response = requests.post( + url, + json={"title": title, "description": body}, + headers=_build_request_headers(access_token), + timeout=GITLAB_API_CALLS_TIMEOUT, + ) + response.raise_for_status() + return response.json() # type: ignore[no-any-return] + + +def post_comment_to_gitlab( + instance_url: str, + access_token: str, + gitlab_project_id: int, + resource_type: GitLabResourceEndpoint, + resource_iid: int, + body: str, +) -> GitLabNote: + """Post a note (comment) on a GitLab issue or merge request.""" + url = ( + f"{instance_url}/api/v4/projects/{gitlab_project_id}" + f"/{resource_type}/{resource_iid}/notes" + ) + response = requests.post( + url, + json={"body": body}, + headers=_build_request_headers(access_token), + timeout=GITLAB_API_CALLS_TIMEOUT, + ) + response.raise_for_status() + return response.json() # type: ignore[no-any-return] + + +def get_gitlab_resource_metadata( + instance_url: str, + access_token: str, + gitlab_project_id: int, + resource_type: GitLabResourceEndpoint, + resource_iid: int, +) -> GitLabResourceMetadata: + """Fetch title and state for a GitLab issue or MR.""" + url = ( + f"{instance_url}/api/v4/projects/{gitlab_project_id}" + f"/{resource_type}/{resource_iid}" + ) + response = requests.get( + url, + headers=_build_request_headers(access_token), + timeout=GITLAB_API_CALLS_TIMEOUT, + ) + response.raise_for_status() + json_response = response.json() + return {"title": json_response["title"], "state": json_response["state"]} + + +def create_flagsmith_flag_label( + instance_url: str, + access_token: str, + gitlab_project_id: int, +) -> GitLabLabel | None: + """Create the Flagsmith Flag label on a GitLab project. + + Returns None if the label already exists. + """ + url = f"{instance_url}/api/v4/projects/{gitlab_project_id}/labels" + response = requests.post( + url, + json={ + "name": GITLAB_FLAGSMITH_LABEL, + "color": f"#{GITLAB_FLAGSMITH_LABEL_COLOUR}", + "description": GITLAB_FLAGSMITH_LABEL_DESCRIPTION, + }, + headers=_build_request_headers(access_token), + timeout=GITLAB_API_CALLS_TIMEOUT, + ) + if response.status_code == 409: + logger.info( + "Flagsmith Flag label already exists on project %s", gitlab_project_id + ) + return None + + response.raise_for_status() + return response.json() # type: ignore[no-any-return] + + +def label_gitlab_resource( + instance_url: str, + access_token: str, + gitlab_project_id: int, + resource_type: GitLabResourceEndpoint, + resource_iid: int, +) -> dict[str, object]: + """Add the Flagsmith Flag label to a GitLab issue or MR.""" + url = ( + f"{instance_url}/api/v4/projects/{gitlab_project_id}" + f"/{resource_type}/{resource_iid}" + ) + response = requests.put( + url, + json={"add_labels": GITLAB_FLAGSMITH_LABEL}, + headers=_build_request_headers(access_token), + timeout=GITLAB_API_CALLS_TIMEOUT, + ) + response.raise_for_status() + return response.json() # type: ignore[no-any-return] diff --git a/api/integrations/gitlab/constants.py b/api/integrations/gitlab/constants.py new file mode 100644 index 000000000000..f3a7a6b53985 --- /dev/null +++ b/api/integrations/gitlab/constants.py @@ -0,0 +1,7 @@ +GITLAB_API_CALLS_TIMEOUT = 10 + +GITLAB_FLAGSMITH_LABEL = "Flagsmith Flag" +GITLAB_FLAGSMITH_LABEL_DESCRIPTION = ( + "This GitLab Issue/MR is linked to a Flagsmith Feature Flag" +) +GITLAB_FLAGSMITH_LABEL_COLOUR = "6633FF" diff --git a/api/integrations/gitlab/dataclasses.py b/api/integrations/gitlab/dataclasses.py new file mode 100644 index 000000000000..cbf0dd97dae2 --- /dev/null +++ b/api/integrations/gitlab/dataclasses.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass, field + + +@dataclass +class PaginatedQueryParams: + page: int = field(default=1, kw_only=True) + page_size: int = field(default=100, kw_only=True) + + def __post_init__(self) -> None: + if self.page < 1: + raise ValueError("Page must be greater or equal than 1") + if self.page_size < 1 or self.page_size > 100: + raise ValueError("Page size must be an integer between 1 and 100") + + +@dataclass +class ProjectQueryParams(PaginatedQueryParams): + gitlab_project_id: int = 0 + project_name: str = "" + + +@dataclass +class IssueQueryParams(ProjectQueryParams): + search_text: str | None = None + state: str | None = "opened" + author: str | None = None + assignee: str | None = None diff --git a/api/integrations/gitlab/migrations/__init__.py b/api/integrations/gitlab/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/gitlab/types.py b/api/integrations/gitlab/types.py new file mode 100644 index 000000000000..607c4736d00f --- /dev/null +++ b/api/integrations/gitlab/types.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import Literal, TypedDict + +from typing_extensions import NotRequired + +GitLabResourceEndpoint = Literal["issues", "merge_requests"] + + +class GitLabProject(TypedDict): + id: int + name: str + path_with_namespace: str + + +class GitLabResource(TypedDict): + web_url: str + id: int + title: str + iid: int + state: str + merged: bool + draft: bool + + +class GitLabMember(TypedDict): + username: str + avatar_url: str + name: str + + +class GitLabNote(TypedDict): + id: int + body: str + + +class GitLabLabel(TypedDict): + id: int + name: str + + +class GitLabResourceMetadata(TypedDict): + title: str + state: str + + +class PaginatedResponse(TypedDict): + results: list[GitLabProject] | list[GitLabResource] | list[GitLabMember] + next: NotRequired[int] + previous: NotRequired[int] + total_count: NotRequired[int] diff --git a/api/tests/unit/integrations/gitlab/__init__.py b/api/tests/unit/integrations/gitlab/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_client.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_client.py new file mode 100644 index 000000000000..e6f274c6d606 --- /dev/null +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_client.py @@ -0,0 +1,442 @@ +import pytest +import requests as _requests +import responses +from requests.exceptions import HTTPError +from responses.matchers import header_matcher, query_param_matcher + +from integrations.gitlab.client import ( + _build_paginated_response, + create_flagsmith_flag_label, + create_gitlab_issue, + fetch_gitlab_project_members, + fetch_gitlab_projects, + fetch_search_gitlab_resource, + get_gitlab_resource_metadata, + label_gitlab_resource, + post_comment_to_gitlab, +) +from integrations.gitlab.dataclasses import ( + IssueQueryParams, + PaginatedQueryParams, + ProjectQueryParams, +) +from integrations.gitlab.types import GitLabProject + +INSTANCE_URL = "https://gitlab.example.com" +ACCESS_TOKEN = "test-access-token" +EXPECTED_HEADERS = {"PRIVATE-TOKEN": ACCESS_TOKEN} + + +@responses.activate +def test_fetch_gitlab_projects__valid_token__returns_projects() -> None: + # Given + responses.add( + responses.GET, + f"{INSTANCE_URL}/api/v4/projects", + json=[ + {"id": 1, "name": "my-project", "path_with_namespace": "group/my-project"}, + {"id": 2, "name": "other", "path_with_namespace": "group/other"}, + ], + match=[ + header_matcher(EXPECTED_HEADERS), + query_param_matcher({"membership": "true", "per_page": "20", "page": "1"}), + ], + status=200, + ) + params = PaginatedQueryParams(page=1, page_size=20) + + # When + result = fetch_gitlab_projects( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + params=params, + ) + + # Then + assert len(result["results"]) == 2 + assert result["results"][0] == { + "id": 1, + "name": "my-project", + "path_with_namespace": "group/my-project", + } + + +@responses.activate +def test_fetch_search_gitlab_resource__issues__returns_results() -> None: + # Given + responses.add( + responses.GET, + f"{INSTANCE_URL}/api/v4/projects/1/issues", + json=[ + { + "id": 10, + "iid": 5, + "title": "Bug fix", + "state": "opened", + "web_url": f"{INSTANCE_URL}/group/project/-/issues/5", + }, + ], + match=[header_matcher(EXPECTED_HEADERS)], + status=200, + headers={"x-total": "1"}, + ) + params = IssueQueryParams(gitlab_project_id=1, project_name="group/project") + + # When + result = fetch_search_gitlab_resource( + resource_type="issues", + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + params=params, + ) + + # Then + results = result["results"] + assert len(results) == 1 + resource = results[0] + assert resource["title"] == "Bug fix" # type: ignore[typeddict-item] + assert resource["merged"] is False # type: ignore[typeddict-item] + assert resource["draft"] is False # type: ignore[typeddict-item] + + +@responses.activate +def test_fetch_search_gitlab_resource__merge_requests__returns_mr_fields() -> None: + # Given + responses.add( + responses.GET, + f"{INSTANCE_URL}/api/v4/projects/1/merge_requests", + json=[ + { + "id": 20, + "iid": 3, + "title": "Add feature", + "state": "merged", + "web_url": f"{INSTANCE_URL}/group/project/-/merge_requests/3", + "merged_at": "2025-01-01T00:00:00Z", + "draft": False, + }, + ], + match=[header_matcher(EXPECTED_HEADERS)], + status=200, + headers={"x-total": "1"}, + ) + params = IssueQueryParams(gitlab_project_id=1, project_name="group/project") + + # When + result = fetch_search_gitlab_resource( + resource_type="merge_requests", + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + params=params, + ) + + # Then + results = result["results"] + assert len(results) == 1 + resource = results[0] + assert resource["merged"] is True # type: ignore[typeddict-item] + assert resource["draft"] is False # type: ignore[typeddict-item] + + +@pytest.mark.parametrize( + "filter_field,filter_value,expected_param", + [ + ("search_text", "my search", "search"), + ("author", "jdoe", "author_username"), + ("assignee", "jsmith", "assignee_username"), + ], +) +@responses.activate +def test_fetch_search_gitlab_resource__with_filter__appends_query_param( + filter_field: str, + filter_value: str, + expected_param: str, +) -> None: + # Given + responses.add( + responses.GET, + f"{INSTANCE_URL}/api/v4/projects/1/issues", + json=[], + status=200, + headers={"x-total": "0"}, + ) + params = IssueQueryParams( + gitlab_project_id=1, + project_name="group/project", + **{filter_field: filter_value}, # type: ignore[arg-type] + ) + + # When + fetch_search_gitlab_resource( + resource_type="issues", + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + params=params, + ) + + # Then + assert len(responses.calls) == 1 + request_url = responses.calls[0].request.url or "" # type: ignore[union-attr] + assert f"{expected_param}=" in request_url + + +@responses.activate +def test_fetch_gitlab_project_members__happy_path__returns_members() -> None: + # Given + responses.add( + responses.GET, + f"{INSTANCE_URL}/api/v4/projects/1/members", + json=[ + { + "username": "jdoe", + "avatar_url": "https://gitlab.example.com/avatar/jdoe", + "name": "John Doe", + } + ], + match=[header_matcher(EXPECTED_HEADERS)], + status=200, + headers={"x-page": "1", "x-total-pages": "1"}, + ) + params = ProjectQueryParams(gitlab_project_id=1, project_name="group/project") + + # When + result = fetch_gitlab_project_members( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + params=params, + ) + + # Then + assert result["results"] == [ + { + "username": "jdoe", + "avatar_url": "https://gitlab.example.com/avatar/jdoe", + "name": "John Doe", + } + ] + + +@responses.activate +def test_post_comment_to_gitlab__issue__posts_note() -> None: + # Given + responses.add( + responses.POST, + f"{INSTANCE_URL}/api/v4/projects/1/issues/5/notes", + json={"id": 100, "body": "test comment"}, + match=[header_matcher(EXPECTED_HEADERS)], + status=201, + ) + + # When + result = post_comment_to_gitlab( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + gitlab_project_id=1, + resource_type="issues", + resource_iid=5, + body="test comment", + ) + + # Then + assert result == {"id": 100, "body": "test comment"} + + +@responses.activate +def test_create_gitlab_issue__valid_data__creates_issue() -> None: + # Given + responses.add( + responses.POST, + f"{INSTANCE_URL}/api/v4/projects/1/issues", + json={ + "iid": 42, + "title": "Cleanup flag", + "state": "opened", + "web_url": f"{INSTANCE_URL}/group/project/-/issues/42", + }, + match=[header_matcher(EXPECTED_HEADERS)], + status=201, + ) + + # When + result = create_gitlab_issue( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + gitlab_project_id=1, + title="Cleanup flag", + body="Remove stale flag", + ) + + # Then + assert result["title"] == "Cleanup flag" + assert result["iid"] == 42 + + +@responses.activate +def test_get_gitlab_resource_metadata__valid_resource__returns_title_and_state() -> ( + None +): + # Given + responses.add( + responses.GET, + f"{INSTANCE_URL}/api/v4/projects/1/issues/5", + json={"title": "Bug fix", "state": "opened", "extra_field": "ignored"}, + match=[header_matcher(EXPECTED_HEADERS)], + status=200, + ) + + # When + result = get_gitlab_resource_metadata( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + gitlab_project_id=1, + resource_type="issues", + resource_iid=5, + ) + + # Then + assert result == {"title": "Bug fix", "state": "opened"} + + +@responses.activate +def test_create_flagsmith_flag_label__happy_path__creates_label() -> None: + # Given + responses.add( + responses.POST, + f"{INSTANCE_URL}/api/v4/projects/1/labels", + json={"id": 10, "name": "Flagsmith Flag"}, + match=[header_matcher(EXPECTED_HEADERS)], + status=201, + ) + + # When + result = create_flagsmith_flag_label( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + gitlab_project_id=1, + ) + + # Then + assert result == {"id": 10, "name": "Flagsmith Flag"} + + +@responses.activate +def test_create_flagsmith_flag_label__already_exists__returns_none() -> None: + # Given + responses.add( + responses.POST, + f"{INSTANCE_URL}/api/v4/projects/1/labels", + json={"message": "Label already exists"}, + status=409, + ) + + # When + result = create_flagsmith_flag_label( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + gitlab_project_id=1, + ) + + # Then + assert result is None + + +@responses.activate +def test_create_flagsmith_flag_label__other_error__raises() -> None: + # Given + responses.add( + responses.POST, + f"{INSTANCE_URL}/api/v4/projects/1/labels", + json={"message": "Forbidden"}, + status=403, + ) + + # When / Then + with pytest.raises(HTTPError): + create_flagsmith_flag_label( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + gitlab_project_id=1, + ) + + +@responses.activate +def test_label_gitlab_resource__happy_path__adds_label() -> None: + # Given + responses.add( + responses.PUT, + f"{INSTANCE_URL}/api/v4/projects/1/issues/5", + json={"id": 5, "labels": ["Flagsmith Flag"]}, + match=[header_matcher(EXPECTED_HEADERS)], + status=200, + ) + + # When + result = label_gitlab_resource( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + gitlab_project_id=1, + resource_type="issues", + resource_iid=5, + ) + + # Then + assert result["labels"] == ["Flagsmith Flag"] + + +@pytest.mark.parametrize( + "x_page,x_total_pages,expect_previous,expect_next", + [ + ("2", "3", 1, 3), + ("1", "3", None, 2), + ("3", "3", 2, None), + ("1", "1", None, None), + ], + ids=["middle-page", "first-page", "last-page", "single-page"], +) +def test_build_paginated_response__pagination_headers__returns_correct_links( + x_page: str, + x_total_pages: str, + expect_previous: int | None, + expect_next: int | None, +) -> None: + # Given + resp = _requests.models.Response() + resp.headers["x-page"] = x_page + resp.headers["x-total-pages"] = x_total_pages + + # When + results: list[GitLabProject] = [ + {"id": 1, "name": "p", "path_with_namespace": "g/p"} + ] + result = _build_paginated_response( + results=results, + response=resp, + total_count=10, + ) + + # Then + assert result.get("previous") == expect_previous + assert result.get("next") == expect_next + assert result["total_count"] == 10 + + +@pytest.mark.parametrize( + "page,page_size,expected_error", + [ + (0, 100, "Page must be greater or equal than 1"), + (-1, 100, "Page must be greater or equal than 1"), + (1, 0, "Page size must be an integer between 1 and 100"), + (1, 101, "Page size must be an integer between 1 and 100"), + ], + ids=["page-zero", "page-negative", "page-size-zero", "page-size-over-100"], +) +def test_paginated_query_params__invalid_values__raises_value_error( + page: int, + page_size: int, + expected_error: str, +) -> None: + # Given + # When + # Then + with pytest.raises(ValueError, match=expected_error): + PaginatedQueryParams(page=page, page_size=page_size)