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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@
"integrations.flagsmith",
"integrations.launch_darkly",
"integrations.github",
"integrations.gitlab",
"integrations.grafana",
# Rate limiting admin endpoints
"axes",
Expand Down
Empty file.
5 changes: 5 additions & 0 deletions api/integrations/gitlab/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.apps import AppConfig


class GitLabIntegrationConfig(AppConfig):
name = "integrations.gitlab"
271 changes: 271 additions & 0 deletions api/integrations/gitlab/client.py
Original file line number Diff line number Diff line change
@@ -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]
7 changes: 7 additions & 0 deletions api/integrations/gitlab/constants.py
Original file line number Diff line number Diff line change
@@ -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"
27 changes: 27 additions & 0 deletions api/integrations/gitlab/dataclasses.py
Original file line number Diff line number Diff line change
@@ -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
Empty file.
51 changes: 51 additions & 0 deletions api/integrations/gitlab/types.py
Original file line number Diff line number Diff line change
@@ -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]
Empty file.
Loading
Loading