Skip to content

Commit 11bf93f

Browse files
asaphkoclaude
andcommitted
feat(gitlab): add GitLab API v4 client with typed responses
Introduce the GitLab integration Django app with a typed API client for GitLab v4. This is the first PR in a stacked series splitting the GitLab integration (#7028) into reviewable increments. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 61ca107 commit 11bf93f

File tree

10 files changed

+769
-0
lines changed

10 files changed

+769
-0
lines changed

api/app/settings/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@
154154
"integrations.flagsmith",
155155
"integrations.launch_darkly",
156156
"integrations.github",
157+
"integrations.gitlab",
157158
"integrations.grafana",
158159
# Rate limiting admin endpoints
159160
"axes",

api/integrations/gitlab/__init__.py

Whitespace-only changes.

api/integrations/gitlab/apps.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.apps import AppConfig
2+
3+
4+
class GitLabIntegrationConfig(AppConfig):
5+
name = "integrations.gitlab"

api/integrations/gitlab/client.py

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import logging
2+
3+
import requests
4+
5+
from integrations.gitlab.constants import (
6+
GITLAB_API_CALLS_TIMEOUT,
7+
GITLAB_FLAGSMITH_LABEL,
8+
GITLAB_FLAGSMITH_LABEL_COLOUR,
9+
GITLAB_FLAGSMITH_LABEL_DESCRIPTION,
10+
)
11+
from integrations.gitlab.dataclasses import (
12+
IssueQueryParams,
13+
PaginatedQueryParams,
14+
ProjectQueryParams,
15+
)
16+
from integrations.gitlab.types import (
17+
GitLabLabel,
18+
GitLabMember,
19+
GitLabNote,
20+
GitLabProject,
21+
GitLabResource,
22+
GitLabResourceEndpoint,
23+
GitLabResourceMetadata,
24+
PaginatedResponse,
25+
)
26+
27+
logger = logging.getLogger(__name__)
28+
29+
30+
def _build_request_headers(access_token: str) -> dict[str, str]:
31+
return {"PRIVATE-TOKEN": access_token}
32+
33+
34+
def _build_paginated_response(
35+
results: list[GitLabProject] | list[GitLabResource] | list[GitLabMember],
36+
response: requests.Response,
37+
total_count: int | None = None,
38+
) -> PaginatedResponse:
39+
data: PaginatedResponse = {"results": results}
40+
41+
current_page = int(response.headers.get("x-page", 1))
42+
total_pages = int(response.headers.get("x-total-pages", 1))
43+
44+
if current_page > 1:
45+
data["previous"] = current_page - 1
46+
if current_page < total_pages:
47+
data["next"] = current_page + 1
48+
49+
if total_count is not None:
50+
data["total_count"] = total_count
51+
52+
return data
53+
54+
55+
def fetch_gitlab_projects(
56+
instance_url: str,
57+
access_token: str,
58+
params: PaginatedQueryParams,
59+
) -> PaginatedResponse:
60+
url = f"{instance_url}/api/v4/projects"
61+
response = requests.get(
62+
url,
63+
headers=_build_request_headers(access_token),
64+
params={"membership": "true", "per_page": str(params.page_size), "page": str(params.page)},
65+
timeout=GITLAB_API_CALLS_TIMEOUT,
66+
)
67+
response.raise_for_status()
68+
69+
results: list[GitLabProject] = [
70+
{
71+
"id": project["id"],
72+
"name": project["name"],
73+
"path_with_namespace": project["path_with_namespace"],
74+
}
75+
for project in response.json()
76+
]
77+
78+
total_count = int(response.headers.get("x-total", len(results)))
79+
return _build_paginated_response(results, response, total_count)
80+
81+
82+
def fetch_search_gitlab_resource(
83+
resource_type: GitLabResourceEndpoint,
84+
instance_url: str,
85+
access_token: str,
86+
params: IssueQueryParams,
87+
) -> PaginatedResponse:
88+
"""Search issues or merge requests in a GitLab project."""
89+
url = (
90+
f"{instance_url}/api/v4/projects/{params.gitlab_project_id}/{resource_type}"
91+
)
92+
query_params: dict[str, str | int] = {
93+
"per_page": params.page_size,
94+
"page": params.page,
95+
}
96+
if params.search_text:
97+
query_params["search"] = params.search_text
98+
if params.state:
99+
query_params["state"] = params.state
100+
if params.author:
101+
query_params["author_username"] = params.author
102+
if params.assignee:
103+
query_params["assignee_username"] = params.assignee
104+
105+
response = requests.get(
106+
url,
107+
headers=_build_request_headers(access_token),
108+
params=query_params,
109+
timeout=GITLAB_API_CALLS_TIMEOUT,
110+
)
111+
response.raise_for_status()
112+
113+
is_mr = resource_type == "merge_requests"
114+
results: list[GitLabResource] = [
115+
{
116+
"web_url": item["web_url"],
117+
"id": item["id"],
118+
"title": item["title"],
119+
"iid": item["iid"],
120+
"state": item["state"],
121+
"merged": item.get("merged_at") is not None if is_mr else False,
122+
"draft": item.get("draft", False) if is_mr else False,
123+
}
124+
for item in response.json()
125+
]
126+
127+
total_count = int(response.headers.get("x-total", len(results)))
128+
return _build_paginated_response(results, response, total_count)
129+
130+
131+
def fetch_gitlab_project_members(
132+
instance_url: str,
133+
access_token: str,
134+
params: ProjectQueryParams,
135+
) -> PaginatedResponse:
136+
url = f"{instance_url}/api/v4/projects/{params.gitlab_project_id}/members"
137+
response = requests.get(
138+
url,
139+
headers=_build_request_headers(access_token),
140+
params={"per_page": params.page_size, "page": params.page},
141+
timeout=GITLAB_API_CALLS_TIMEOUT,
142+
)
143+
response.raise_for_status()
144+
145+
results: list[GitLabMember] = [
146+
{
147+
"username": member["username"],
148+
"avatar_url": member["avatar_url"],
149+
"name": member["name"],
150+
}
151+
for member in response.json()
152+
]
153+
154+
return _build_paginated_response(results, response)
155+
156+
157+
def create_gitlab_issue(
158+
instance_url: str,
159+
access_token: str,
160+
gitlab_project_id: int,
161+
title: str,
162+
body: str,
163+
) -> dict[str, object]:
164+
url = f"{instance_url}/api/v4/projects/{gitlab_project_id}/issues"
165+
response = requests.post(
166+
url,
167+
json={"title": title, "description": body},
168+
headers=_build_request_headers(access_token),
169+
timeout=GITLAB_API_CALLS_TIMEOUT,
170+
)
171+
response.raise_for_status()
172+
return response.json() # type: ignore[no-any-return]
173+
174+
175+
def post_comment_to_gitlab(
176+
instance_url: str,
177+
access_token: str,
178+
gitlab_project_id: int,
179+
resource_type: GitLabResourceEndpoint,
180+
resource_iid: int,
181+
body: str,
182+
) -> GitLabNote:
183+
"""Post a note (comment) on a GitLab issue or merge request."""
184+
url = (
185+
f"{instance_url}/api/v4/projects/{gitlab_project_id}"
186+
f"/{resource_type}/{resource_iid}/notes"
187+
)
188+
response = requests.post(
189+
url,
190+
json={"body": body},
191+
headers=_build_request_headers(access_token),
192+
timeout=GITLAB_API_CALLS_TIMEOUT,
193+
)
194+
response.raise_for_status()
195+
return response.json() # type: ignore[no-any-return]
196+
197+
198+
def get_gitlab_resource_metadata(
199+
instance_url: str,
200+
access_token: str,
201+
gitlab_project_id: int,
202+
resource_type: GitLabResourceEndpoint,
203+
resource_iid: int,
204+
) -> GitLabResourceMetadata:
205+
"""Fetch title and state for a GitLab issue or MR."""
206+
url = (
207+
f"{instance_url}/api/v4/projects/{gitlab_project_id}"
208+
f"/{resource_type}/{resource_iid}"
209+
)
210+
response = requests.get(
211+
url,
212+
headers=_build_request_headers(access_token),
213+
timeout=GITLAB_API_CALLS_TIMEOUT,
214+
)
215+
response.raise_for_status()
216+
json_response = response.json()
217+
return {"title": json_response["title"], "state": json_response["state"]}
218+
219+
220+
def create_flagsmith_flag_label(
221+
instance_url: str,
222+
access_token: str,
223+
gitlab_project_id: int,
224+
) -> GitLabLabel | None:
225+
"""Create the Flagsmith Flag label on a GitLab project.
226+
227+
Returns None if the label already exists.
228+
"""
229+
url = f"{instance_url}/api/v4/projects/{gitlab_project_id}/labels"
230+
response = requests.post(
231+
url,
232+
json={
233+
"name": GITLAB_FLAGSMITH_LABEL,
234+
"color": f"#{GITLAB_FLAGSMITH_LABEL_COLOUR}",
235+
"description": GITLAB_FLAGSMITH_LABEL_DESCRIPTION,
236+
},
237+
headers=_build_request_headers(access_token),
238+
timeout=GITLAB_API_CALLS_TIMEOUT,
239+
)
240+
if response.status_code == 409:
241+
logger.info("Flagsmith Flag label already exists on project %s", gitlab_project_id)
242+
return None
243+
244+
response.raise_for_status()
245+
return response.json() # type: ignore[no-any-return]
246+
247+
248+
def label_gitlab_resource(
249+
instance_url: str,
250+
access_token: str,
251+
gitlab_project_id: int,
252+
resource_type: GitLabResourceEndpoint,
253+
resource_iid: int,
254+
) -> dict[str, object]:
255+
"""Add the Flagsmith Flag label to a GitLab issue or MR."""
256+
url = (
257+
f"{instance_url}/api/v4/projects/{gitlab_project_id}"
258+
f"/{resource_type}/{resource_iid}"
259+
)
260+
response = requests.put(
261+
url,
262+
json={"add_labels": GITLAB_FLAGSMITH_LABEL},
263+
headers=_build_request_headers(access_token),
264+
timeout=GITLAB_API_CALLS_TIMEOUT,
265+
)
266+
response.raise_for_status()
267+
return response.json() # type: ignore[no-any-return]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
GITLAB_API_CALLS_TIMEOUT = 10
2+
3+
GITLAB_FLAGSMITH_LABEL = "Flagsmith Flag"
4+
GITLAB_FLAGSMITH_LABEL_DESCRIPTION = (
5+
"This GitLab Issue/MR is linked to a Flagsmith Feature Flag"
6+
)
7+
GITLAB_FLAGSMITH_LABEL_COLOUR = "6633FF"
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from dataclasses import dataclass, field
2+
3+
4+
@dataclass
5+
class PaginatedQueryParams:
6+
page: int = field(default=1, kw_only=True)
7+
page_size: int = field(default=100, kw_only=True)
8+
9+
def __post_init__(self) -> None:
10+
if self.page < 1:
11+
raise ValueError("Page must be greater or equal than 1")
12+
if self.page_size < 1 or self.page_size > 100:
13+
raise ValueError("Page size must be an integer between 1 and 100")
14+
15+
16+
@dataclass
17+
class ProjectQueryParams(PaginatedQueryParams):
18+
gitlab_project_id: int = 0
19+
project_name: str = ""
20+
21+
22+
@dataclass
23+
class IssueQueryParams(ProjectQueryParams):
24+
search_text: str | None = None
25+
state: str | None = "opened"
26+
author: str | None = None
27+
assignee: str | None = None

api/integrations/gitlab/migrations/__init__.py

Whitespace-only changes.

api/integrations/gitlab/types.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from __future__ import annotations
2+
3+
from typing import Literal, TypedDict
4+
5+
from typing_extensions import NotRequired
6+
7+
GitLabResourceEndpoint = Literal["issues", "merge_requests"]
8+
9+
10+
class GitLabProject(TypedDict):
11+
id: int
12+
name: str
13+
path_with_namespace: str
14+
15+
16+
class GitLabResource(TypedDict):
17+
web_url: str
18+
id: int
19+
title: str
20+
iid: int
21+
state: str
22+
merged: bool
23+
draft: bool
24+
25+
26+
class GitLabMember(TypedDict):
27+
username: str
28+
avatar_url: str
29+
name: str
30+
31+
32+
class GitLabNote(TypedDict):
33+
id: int
34+
body: str
35+
36+
37+
class GitLabLabel(TypedDict):
38+
id: int
39+
name: str
40+
41+
42+
class GitLabResourceMetadata(TypedDict):
43+
title: str
44+
state: str
45+
46+
47+
class PaginatedResponse(TypedDict):
48+
results: list[GitLabProject] | list[GitLabResource] | list[GitLabMember]
49+
next: NotRequired[int]
50+
previous: NotRequired[int]
51+
total_count: NotRequired[int]

api/tests/unit/integrations/gitlab/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)