Coverage for repo_ctx / providers / gitlab.py: 14%
111 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-25 17:42 +0100
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-25 17:42 +0100
1"""GitLab provider implementation."""
2import gitlab
3import base64
4import json
5from typing import Optional, List
6from .base import GitProvider, ProviderProject, ProviderFile
7from .exceptions import (
8 ProviderNotFoundError,
9 ProviderAuthError,
10 ProviderFileNotFoundError,
11 ProviderError,
12 ProviderRateLimitError
13)
16class GitLabProvider(GitProvider):
17 """GitLab repository provider using python-gitlab."""
19 def __init__(self, url: str, token: str):
20 """
21 Initialize GitLab provider.
23 Args:
24 url: GitLab instance URL
25 token: Personal access token with read_api scope
27 Raises:
28 ProviderAuthError: Authentication failed
29 """
30 self.url = url
31 self.token = token
32 try:
33 self.client = gitlab.Gitlab(url, private_token=token)
34 self.client.auth()
35 except gitlab.exceptions.GitlabAuthenticationError as e:
36 raise ProviderAuthError(f"GitLab authentication failed: {e}")
37 except Exception as e:
38 raise ProviderError(f"Failed to initialize GitLab client: {e}")
40 async def get_project(self, path: str) -> ProviderProject:
41 """
42 Get project metadata from GitLab.
44 Args:
45 path: Project path (format: group/project or group/subgroup/project)
47 Returns:
48 ProviderProject with normalized metadata
50 Raises:
51 ProviderNotFoundError: Project doesn't exist
52 ProviderAuthError: Authentication failed
53 """
54 try:
55 project = self.client.projects.get(path)
57 return ProviderProject(
58 id=str(project.id),
59 name=project.name,
60 path=project.path_with_namespace,
61 description=project.description,
62 default_branch=project.default_branch,
63 web_url=project.web_url
64 )
65 except gitlab.exceptions.GitlabGetError as e:
66 if e.response_code == 404:
67 raise ProviderNotFoundError(f"GitLab project not found: {path}")
68 elif e.response_code == 401:
69 raise ProviderAuthError(f"Authentication failed for project: {path}")
70 else:
71 raise ProviderError(f"Error getting GitLab project {path}: {e}")
72 except Exception as e:
73 raise ProviderError(f"Unexpected error getting project {path}: {e}")
75 async def get_default_branch(self, project: ProviderProject) -> str:
76 """
77 Get default branch name.
79 Args:
80 project: Project to query
82 Returns:
83 Default branch name (e.g., "main", "master")
84 """
85 # Already in ProviderProject, but fetch fresh if needed
86 if project.default_branch:
87 return project.default_branch
89 # Fallback: fetch from API
90 try:
91 gitlab_project = self.client.projects.get(project.id)
92 return gitlab_project.default_branch
93 except Exception as e:
94 raise ProviderError(f"Error getting default branch: {e}")
96 async def get_file_tree(
97 self,
98 project: ProviderProject,
99 ref: str,
100 recursive: bool = True
101 ) -> List[str]:
102 """
103 Get list of all file paths in repository.
105 Args:
106 project: Project to query
107 ref: Branch, tag, or commit SHA
108 recursive: Include subdirectories
110 Returns:
111 List of file paths relative to repo root
113 Raises:
114 ProviderError: Error accessing file tree
115 """
116 try:
117 gitlab_project = self.client.projects.get(project.id)
118 tree = gitlab_project.repository_tree(
119 ref=ref,
120 recursive=recursive,
121 all=True
122 )
124 # Extract file paths from tree objects
125 file_paths = []
126 for item in tree:
127 if item['type'] == 'blob': # Files only, not directories
128 file_paths.append(item['path'])
130 return file_paths
132 except gitlab.exceptions.GitlabGetError as e:
133 # If ref not found, try with default branch
134 if e.response_code == 404:
135 raise ProviderError(
136 f"Branch/tag '{ref}' not found in project {project.path}"
137 )
138 else:
139 raise ProviderError(f"Error getting file tree: {e}")
140 except Exception as e:
141 raise ProviderError(f"Unexpected error getting file tree: {e}")
143 async def read_file(
144 self,
145 project: ProviderProject,
146 path: str,
147 ref: str
148 ) -> ProviderFile:
149 """
150 Read file contents from GitLab.
152 Args:
153 project: Project to query
154 path: File path relative to repo root
155 ref: Branch, tag, or commit SHA
157 Returns:
158 ProviderFile with content and metadata
160 Raises:
161 ProviderFileNotFoundError: File doesn't exist at ref
162 """
163 try:
164 gitlab_project = self.client.projects.get(project.id)
165 file_data = gitlab_project.files.get(file_path=path, ref=ref)
167 # Decode content if base64 encoded
168 content = file_data.content
169 if file_data.encoding == 'base64':
170 content = base64.b64decode(content).decode('utf-8')
172 return ProviderFile(
173 path=path,
174 content=content,
175 size=file_data.size if hasattr(file_data, 'size') else len(content)
176 )
178 except gitlab.exceptions.GitlabGetError as e:
179 if e.response_code == 404:
180 raise ProviderFileNotFoundError(
181 f"File '{path}' not found in {project.path} at ref '{ref}'"
182 )
183 else:
184 raise ProviderError(f"Error reading file {path}: {e}")
185 except UnicodeDecodeError:
186 raise ProviderError(
187 f"File '{path}' is not valid UTF-8 (binary file?)"
188 )
189 except Exception as e:
190 raise ProviderError(f"Unexpected error reading file {path}: {e}")
192 async def read_config(
193 self,
194 project: ProviderProject,
195 ref: str
196 ) -> Optional[dict]:
197 """
198 Read repo-ctx configuration file if it exists.
200 Searches for configuration files in this order:
201 1. git_context.json (current name)
202 2. .git_context.json
203 3. repo_context.json
204 4. .repo-ctx.json
206 Args:
207 project: Project to query
208 ref: Branch, tag, or commit SHA
210 Returns:
211 Parsed JSON config or None if not found
212 """
213 config_filenames = [
214 "git_context.json",
215 ".git_context.json",
216 "repo_context.json",
217 ".repo-ctx.json"
218 ]
220 for filename in config_filenames:
221 try:
222 file = await self.read_file(project, filename, ref)
223 return json.loads(file.content)
224 except ProviderFileNotFoundError:
225 # Try next filename
226 continue
227 except json.JSONDecodeError as e:
228 raise ProviderError(
229 f"Invalid JSON in config file {filename}: {e}"
230 )
231 except Exception:
232 # Try next filename
233 continue
235 # No config file found
236 return None
238 async def get_tags(
239 self,
240 project: ProviderProject,
241 limit: int = 5
242 ) -> List[str]:
243 """
244 Get repository tags (most recent first).
246 Args:
247 project: Project to query
248 limit: Maximum number of tags to return
250 Returns:
251 List of tag names
253 Raises:
254 ProviderError: Error accessing tags
255 """
256 try:
257 gitlab_project = self.client.projects.get(project.id)
258 tags = gitlab_project.tags.list(all=True)
260 # GitLab returns tags in reverse chronological order by default
261 tag_names = [tag.name for tag in tags[:limit]]
263 return tag_names
265 except gitlab.exceptions.GitlabError as e:
266 raise ProviderError(f"Error getting tags: {e}")
267 except Exception as e:
268 raise ProviderError(f"Unexpected error getting tags: {e}")
270 async def list_projects_in_group(
271 self,
272 group_path: str,
273 include_subgroups: bool = True
274 ) -> List[ProviderProject]:
275 """
276 List all projects in a GitLab group.
278 Args:
279 group_path: Group path (e.g., "mygroup" or "mygroup/subgroup")
280 include_subgroups: Include projects from nested subgroups
282 Returns:
283 List of projects in the group
285 Raises:
286 ProviderNotFoundError: Group not found
287 ProviderError: Error accessing group
288 """
289 try:
290 group = self.client.groups.get(group_path)
291 projects = group.projects.list(
292 all=True,
293 include_subgroups=include_subgroups
294 )
296 result = []
297 for proj in projects:
298 result.append(ProviderProject(
299 id=str(proj.id),
300 name=proj.name,
301 path=proj.path_with_namespace,
302 description=getattr(proj, 'description', None),
303 default_branch=getattr(proj, 'default_branch', 'main'),
304 web_url=getattr(proj, 'web_url', None)
305 ))
307 return result
309 except gitlab.exceptions.GitlabGetError as e:
310 if e.response_code == 404:
311 raise ProviderNotFoundError(f"GitLab group not found: {group_path}")
312 elif e.response_code == 401:
313 raise ProviderAuthError(
314 f"Authentication failed for group: {group_path}"
315 )
316 else:
317 raise ProviderError(f"Error getting group {group_path}: {e}")
318 except gitlab.exceptions.GitlabHttpError as e:
319 if e.response_code == 429:
320 raise ProviderRateLimitError(
321 f"GitLab rate limit exceeded for group: {group_path}"
322 )
323 else:
324 raise ProviderError(f"HTTP error accessing group {group_path}: {e}")
325 except Exception as e:
326 raise ProviderError(
327 f"Unexpected error listing projects in group {group_path}: {e}"
328 )