Skip to main content
Glama
gitlab_client.py140 kB
"""Simplified GitLab client used by the unit tests. The original project contains a very feature rich client that mirrors large parts of the GitLab API. Re‑creating the full implementation would be unnecessary for the exercises, so this module provides a small, self contained client that implements only the functionality required by the tests. The methods intentionally operate on objects returned by the stub ``gitlab`` package and mostly convert those objects into plain dictionaries. """ from __future__ import annotations from dataclasses import dataclass from typing import Any, Dict, List, Optional import base64 import logging import gitlab from .git_detector import GitDetector from .constants import ( DEFAULT_GITLAB_URL, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, SMALL_PAGE_SIZE, DEFAULT_MAX_BODY_LENGTH, CACHE_TTL_MEDIUM, ) from .utils import timed_cache, retry_on_error logger = logging.getLogger(__name__) @dataclass class GitLabConfig: """Configuration for :class:`GitLabClient`.""" url: str = DEFAULT_GITLAB_URL private_token: Optional[str] = None oauth_token: Optional[str] = None class GitLabClient: """Very small wrapper around the :mod:`gitlab` stub. The class only implements the handful of operations that are exercised in the tests. Each public method returns simple dictionaries so that the behaviour is easy to assert. """ def __init__(self, config: GitLabConfig): self.config = config auth_kwargs: Dict[str, Any] = {} if config.private_token: auth_kwargs["private_token"] = config.private_token elif config.oauth_token: auth_kwargs["oauth_token"] = config.oauth_token else: # pragma: no cover - validated by tests raise ValueError("Either private_token or oauth_token must be provided") self.gl = gitlab.Gitlab(config.url, **auth_kwargs) # The real client would perform an HTTP request here. The stubbed # version simply provides the ``auth`` method so the call is harmless. self.gl.auth() # ------------------------------------------------------------------ # Helper conversion utilities # ------------------------------------------------------------------ @staticmethod def _project_to_dict(project: Any) -> Dict[str, Any]: return { "id": getattr(project, "id", None), "name": getattr(project, "name", None), "path": getattr(project, "path", None), "path_with_namespace": getattr(project, "path_with_namespace", None), "description": getattr(project, "description", None), "web_url": getattr(project, "web_url", None), "visibility": getattr(project, "visibility", None), "last_activity_at": getattr(project, "last_activity_at", None), } @staticmethod def _issue_to_dict(issue: Any) -> Dict[str, Any]: return { "id": getattr(issue, "id", None), "iid": getattr(issue, "iid", None), "title": getattr(issue, "title", None), "description": getattr(issue, "description", None), "state": getattr(issue, "state", None), "created_at": getattr(issue, "created_at", None), "updated_at": getattr(issue, "updated_at", None), "labels": getattr(issue, "labels", []), "web_url": getattr(issue, "web_url", None), "author": getattr(issue, "author", None), } @staticmethod def _mr_to_dict(mr: Any) -> Dict[str, Any]: return { "id": getattr(mr, "id", None), "iid": getattr(mr, "iid", None), "title": getattr(mr, "title", None), "description": getattr(mr, "description", None), "state": getattr(mr, "state", None), "source_branch": getattr(mr, "source_branch", None), "target_branch": getattr(mr, "target_branch", None), "created_at": getattr(mr, "created_at", None), "updated_at": getattr(mr, "updated_at", None), "web_url": getattr(mr, "web_url", None), "author": getattr(mr, "author", None), } @staticmethod def _note_to_dict(note: Any, max_body_length: int) -> Dict[str, Any]: body = getattr(note, "body", "") truncated = False if max_body_length and len(body) > max_body_length: body = body[:max_body_length] + "... [truncated]" truncated = True data: Dict[str, Any] = { "id": getattr(note, "id", None), "body": body, "created_at": getattr(note, "created_at", None), "updated_at": getattr(note, "updated_at", None), "author": getattr(note, "author", None), "system": getattr(note, "system", False), "noteable_type": getattr(note, "noteable_type", None), "noteable_iid": getattr(note, "noteable_iid", None), "resolvable": getattr(note, "resolvable", False), "resolved": getattr(note, "resolved", False), } if truncated: data["truncated"] = True return data @staticmethod def _branch_to_dict(branch: Any) -> Dict[str, Any]: return { "name": getattr(branch, "name", None), "merged": getattr(branch, "merged", False), "protected": getattr(branch, "protected", False), "default": getattr(branch, "default", False), "web_url": getattr(branch, "web_url", None), } # Helper methods for internal use def _get_user_info(self, username: str) -> Dict[str, Any]: """Get user info by username - internal helper method""" user = self.get_user_by_username(username) if not user: return {"username": username, "error": "User not found"} return user def _get_project(self, project_id: str) -> Any: """Get project object by ID - internal helper method""" return self.gl.projects.get(project_id) @staticmethod def _event_to_dict(event: Any) -> Dict[str, Any]: return { "id": getattr(event, "id", None), "title": getattr(event, "title", None), "project_id": getattr(event, "project_id", None), "action_name": getattr(event, "action_name", None), "target_id": getattr(event, "target_id", None), "target_type": getattr(event, "target_type", None), "target_title": getattr(event, "target_title", None), "created_at": getattr(event, "created_at", None), "author_id": getattr(event, "author_id", None), "author_username": getattr(event, "author_username", None), } # ------------------------------------------------------------------ # API methods # ------------------------------------------------------------------ @retry_on_error() def get_projects( self, owned: bool = False, search: Optional[str] = None, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1, ) -> Dict[str, Any]: """Return a list of projects accessible to the user.""" kwargs = { "owned": owned, "membership": True, "get_all": False, "per_page": min(per_page, MAX_PAGE_SIZE), "page": page, } if search: kwargs["search"] = search response = self.gl.projects.list(**kwargs) pagination = { "page": page, "per_page": per_page, "total": getattr(response, "total", None), "total_pages": getattr(response, "total_pages", None), "next_page": getattr(response, "next_page", None), "prev_page": getattr(response, "prev_page", None), } return { "projects": [self._project_to_dict(p) for p in response], "pagination": pagination, } @timed_cache(seconds=CACHE_TTL_MEDIUM) @retry_on_error() def get_project(self, project_id: str) -> Dict[str, Any]: project = self.gl.projects.get(project_id) return self._project_to_dict(project) @retry_on_error() def get_issues( self, project_id: str, state: str = "opened", per_page: int = DEFAULT_PAGE_SIZE, page: int = 1, ) -> Dict[str, Any]: project = self.gl.projects.get(project_id) kwargs = { "state": state, "get_all": False, "per_page": min(per_page, MAX_PAGE_SIZE), "page": page, } response = project.issues.list(**kwargs) pagination = { "page": page, "per_page": per_page, "total": getattr(response, "total", None), "total_pages": getattr(response, "total_pages", None), "next_page": getattr(response, "next_page", None), "prev_page": getattr(response, "prev_page", None), } return { "issues": [self._issue_to_dict(i) for i in response], "pagination": pagination, "project_id": project_id, } @retry_on_error() def get_issue(self, project_id: str, issue_iid: int) -> Dict[str, Any]: project = self.gl.projects.get(project_id) issue = project.issues.get(issue_iid, lazy=False) return self._issue_to_dict(issue) @retry_on_error() def get_merge_requests( self, project_id: str, state: str = "opened", per_page: int = DEFAULT_PAGE_SIZE, page: int = 1, ) -> Dict[str, Any]: project = self.gl.projects.get(project_id) kwargs = { "state": state, "get_all": False, "per_page": min(per_page, MAX_PAGE_SIZE), "page": page, } response = project.mergerequests.list(**kwargs) pagination = { "page": page, "per_page": per_page, "total": getattr(response, "total", None), "total_pages": getattr(response, "total_pages", None), "next_page": getattr(response, "next_page", None), "prev_page": getattr(response, "prev_page", None), } return { "merge_requests": [self._mr_to_dict(m) for m in response], "pagination": pagination, } @retry_on_error() def get_merge_request(self, project_id: str, mr_iid: int) -> Dict[str, Any]: project = self.gl.projects.get(project_id) mr = project.mergerequests.get(mr_iid) return self._mr_to_dict(mr) @retry_on_error() def get_merge_request_approvals(self, project_id: str, mr_iid: int) -> Dict[str, Any]: """Get approval status and details for a merge request. Args: project_id: The ID or path of the project mr_iid: The IID of the merge request Returns: Dict containing approval information """ try: project = self.gl.projects.get(project_id) mr = project.mergerequests.get(mr_iid) # Try to get approvals if available approvals = {} if hasattr(mr, 'approvals'): approval_obj = mr.approvals.get() approvals = { "approvals_required": getattr(approval_obj, "approvals_required", 0), "approvals_left": getattr(approval_obj, "approvals_left", 0), "approved": getattr(approval_obj, "approved", False), "approved_by": [ { "user": { "id": getattr(user.user, "id", None), "username": getattr(user.user, "username", None), "name": getattr(user.user, "name", None), } } for user in getattr(approval_obj, "approved_by", []) ], "suggested_approvers": [ { "id": getattr(user, "id", None), "username": getattr(user, "username", None), "name": getattr(user, "name", None), } for user in getattr(approval_obj, "suggested_approvers", []) ], } return { "mr_iid": mr_iid, "title": getattr(mr, "title", None), "approvals": approvals, "state": getattr(mr, "state", None), } except Exception as e: # Return basic info even if approvals not available return { "mr_iid": mr_iid, "approvals": {}, "error": f"Approvals may not be available: {str(e)}" } @retry_on_error() def get_merge_request_changes(self, project_id: str, mr_iid: int) -> Dict[str, Any]: """Get the changes/diff for a merge request. Args: project_id: The ID or path of the project mr_iid: The IID of the merge request Returns: Dict containing MR changes """ try: project = self.gl.projects.get(project_id) mr = project.mergerequests.get(mr_iid) changes = mr.changes() return { "mr_iid": mr_iid, "title": getattr(mr, "title", None), "changes": [ { "old_path": c.get("old_path"), "new_path": c.get("new_path"), "diff": c.get("diff", "")[:1000], # Truncate large diffs "new_file": c.get("new_file", False), "renamed_file": c.get("renamed_file", False), "deleted_file": c.get("deleted_file", False), } for c in changes.get("changes", []) ], "changes_count": len(changes.get("changes", [])), } except gitlab.exceptions.GitlabGetError as e: return {"error": f"Failed to get MR changes: {str(e)}"} @retry_on_error() def get_merge_request_discussions(self, project_id: str, mr_iid: int) -> Dict[str, Any]: """Get all discussions for a merge request. Args: project_id: The ID or path of the project mr_iid: The IID of the merge request Returns: Dict containing discussions """ try: project = self.gl.projects.get(project_id) mr = project.mergerequests.get(mr_iid) discussions = mr.discussions.list(get_all=True) return { "mr_iid": mr_iid, "title": getattr(mr, "title", None), "discussions": [ { "id": getattr(d, "id", None), "individual_note": getattr(d, "individual_note", False), "notes": [ { "id": getattr(note, "id", None), "body": getattr(note, "body", "")[:500], "author": { "username": getattr(note.author, "username", None) if hasattr(note, "author") else None }, "created_at": getattr(note, "created_at", None), "resolved": getattr(note, "resolved", False), "resolvable": getattr(note, "resolvable", False), } for note in getattr(d, "notes", []) ], } for d in discussions ], "discussions_count": len(discussions), } except gitlab.exceptions.GitlabGetError as e: return {"error": f"Failed to get discussions: {str(e)}"} def get_merge_request_notes( self, project_id: str, mr_iid: int, per_page: int = SMALL_PAGE_SIZE, page: int = 1, sort: str = "asc", order_by: str = "created_at", max_body_length: int = DEFAULT_MAX_BODY_LENGTH, ) -> Dict[str, Any]: project = self.gl.projects.get(project_id) mr = project.mergerequests.get(mr_iid) kwargs = { "get_all": False, "per_page": min(per_page, MAX_PAGE_SIZE), "page": page, "sort": sort, "order_by": order_by, } response = mr.notes.list(**kwargs) pagination = { "page": page, "per_page": per_page, "total": getattr(response, "total", None), "total_pages": getattr(response, "total_pages", None), "next_page": getattr(response, "next_page", None), "prev_page": getattr(response, "prev_page", None), } notes = [self._note_to_dict(n, max_body_length) for n in response] # Some mocks used in the tests don't define ``iid`` so fall back to the # value that was requested. merge_request = { "iid": mr_iid, "title": getattr(mr, "title", None), "web_url": getattr(mr, "web_url", None), } return {"notes": notes, "pagination": pagination, "merge_request": merge_request} # Merge Request Action Methods @retry_on_error() def update_merge_request(self, project_id: str, mr_iid: int, **kwargs) -> Dict[str, Any]: """Update a merge request. Args: project_id: The ID or path of the project mr_iid: The IID of the merge request **kwargs: Fields to update (title, description, state_event, etc.) Returns: Dict with updated merge request data """ try: project = self.gl.projects.get(project_id) mr = project.mergerequests.get(mr_iid) mr.save(**kwargs) return self._mr_to_dict(mr) except gitlab.exceptions.GitlabUpdateError as e: return {"error": f"Failed to update merge request: {str(e)}"} @retry_on_error() def close_merge_request(self, project_id: str, mr_iid: int) -> Dict[str, Any]: """Close a merge request. Args: project_id: The ID or path of the project mr_iid: The IID of the merge request Returns: Dict with closure status """ try: project = self.gl.projects.get(project_id) mr = project.mergerequests.get(mr_iid) mr.state_event = "close" mr.save() return { "success": True, "message": f"Merge request !{mr_iid} closed successfully", "mr_iid": mr_iid, "state": "closed" } except gitlab.exceptions.GitlabUpdateError as e: return {"error": f"Failed to close merge request: {str(e)}"} @retry_on_error() def merge_merge_request(self, project_id: str, mr_iid: int, merge_commit_message: Optional[str] = None, squash: bool = False, should_remove_source_branch: bool = False) -> Dict[str, Any]: """Merge a merge request. Args: project_id: The ID or path of the project mr_iid: The IID of the merge request merge_commit_message: Optional custom merge commit message squash: Whether to squash commits should_remove_source_branch: Whether to remove source branch after merge Returns: Dict with merge status """ try: project = self.gl.projects.get(project_id) mr = project.mergerequests.get(mr_iid) kwargs = {} if merge_commit_message: kwargs["merge_commit_message"] = merge_commit_message if squash: kwargs["squash"] = squash if should_remove_source_branch: kwargs["should_remove_source_branch"] = should_remove_source_branch mr.merge(**kwargs) return { "success": True, "message": f"Merge request !{mr_iid} merged successfully", "mr_iid": mr_iid, "state": "merged" } except gitlab.exceptions.GitlabUpdateError as e: return {"error": f"Failed to merge request: {str(e)}"} except Exception as e: return {"error": f"Merge failed: {str(e)}"} @retry_on_error() def rebase_merge_request(self, project_id: str, mr_iid: int) -> Dict[str, Any]: """Rebase a merge request to the latest target branch. Args: project_id: The ID or path of the project mr_iid: The IID of the merge request Returns: Dict with rebase status """ try: project = self.gl.projects.get(project_id) mr = project.mergerequests.get(mr_iid) mr.rebase() return { "success": True, "message": f"Merge request !{mr_iid} rebased successfully", "mr_iid": mr_iid, "project_id": project_id } except gitlab.exceptions.GitlabUpdateError as e: return {"error": f"Failed to rebase merge request: {str(e)}"} except gitlab.exceptions.GitlabHttpError as e: return {"error": f"Rebase failed: {str(e)}"} except Exception as e: return {"error": f"Rebase failed: {str(e)}"} @retry_on_error() def add_merge_request_comment(self, project_id: str, mr_iid: int, body: str) -> Dict[str, Any]: """Add a comment to a merge request. Args: project_id: The ID or path of the project mr_iid: The IID of the merge request body: The comment text Returns: Dict with the created comment """ try: project = self.gl.projects.get(project_id) mr = project.mergerequests.get(mr_iid) note = mr.notes.create({"body": body}) return self._note_to_dict(note, max_body_length=None) except gitlab.exceptions.GitlabCreateError as e: return {"error": f"Failed to add comment: {str(e)}"} @retry_on_error() def resolve_discussion(self, project_id: str, mr_iid: int, discussion_id: str) -> Dict[str, Any]: """Resolve a discussion thread on a merge request. Args: project_id: The ID or path of the project mr_iid: The IID of the merge request discussion_id: The ID of the discussion to resolve Returns: Dict with resolution status """ try: project = self.gl.projects.get(project_id) mr = project.mergerequests.get(mr_iid) discussion = mr.discussions.get(discussion_id) discussion.resolved = True discussion.save() return { "success": True, "message": f"Discussion {discussion_id} resolved successfully", "discussion_id": discussion_id, "resolved": True } except gitlab.exceptions.GitlabUpdateError as e: return {"error": f"Failed to resolve discussion: {str(e)}"} except Exception as e: return {"error": f"Resolution failed: {str(e)}"} @retry_on_error() def approve_merge_request(self, project_id: str, mr_iid: int) -> Dict[str, Any]: """Approve a merge request. Args: project_id: The ID or path of the project mr_iid: The IID of the merge request Returns: Dict with approval status """ try: project = self.gl.projects.get(project_id) mr = project.mergerequests.get(mr_iid) approval = mr.approve() return { "success": True, "message": f"Merge request !{mr_iid} approved successfully", "mr_iid": mr_iid, "project_id": project_id, "approved_by": getattr(approval, "user", {}) } except gitlab.exceptions.GitlabUpdateError as e: return {"error": f"Failed to approve merge request: {str(e)}"} except gitlab.exceptions.GitlabHttpError as e: return {"error": f"Approval failed: {str(e)}"} except Exception as e: return {"error": f"Approval failed: {str(e)}"} # Issue Action Methods @retry_on_error() def add_issue_comment(self, project_id: str, issue_iid: int, body: str) -> Dict[str, Any]: """Add a comment to an issue. Args: project_id: The ID or path of the project issue_iid: The IID of the issue body: The comment text Returns: Dict with the created comment """ try: project = self.gl.projects.get(project_id) issue = project.issues.get(issue_iid) note = issue.notes.create({"body": body}) return self._note_to_dict(note, max_body_length=None) except gitlab.exceptions.GitlabCreateError as e: return {"error": f"Failed to add comment: {str(e)}"} # Project Management Methods @retry_on_error() def get_project_members(self, project_id: str, query: Optional[str] = None, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1) -> Dict[str, Any]: """Get project members. Args: project_id: The ID or path of the project query: Optional search query for member names per_page: Number of items per page page: Page number Returns: Dict containing project members """ try: project = self.gl.projects.get(project_id) kwargs = {"get_all": False, "per_page": per_page, "page": page} if query: kwargs["query"] = query members = project.members.list(**kwargs) return { "members": [ { "id": getattr(m, "id", None), "username": getattr(m, "username", None), "name": getattr(m, "name", None), "state": getattr(m, "state", None), "access_level": getattr(m, "access_level", None), "expires_at": getattr(m, "expires_at", None), "avatar_url": getattr(m, "avatar_url", None), "web_url": getattr(m, "web_url", None), } for m in members ], "pagination": { "page": page, "per_page": per_page, "total": getattr(members, "total", None), "total_pages": getattr(members, "total_pages", None), } } except gitlab.exceptions.GitlabGetError as e: return {"error": f"Failed to get project members: {str(e)}"} @retry_on_error() def get_project_hooks(self, project_id: str) -> List[Dict[str, Any]]: """Get project webhooks. Args: project_id: The ID or path of the project Returns: List of project webhooks """ try: project = self.gl.projects.get(project_id) hooks = project.hooks.list(get_all=True) return [ { "id": getattr(h, "id", None), "url": getattr(h, "url", None), "created_at": getattr(h, "created_at", None), "push_events": getattr(h, "push_events", False), "tag_push_events": getattr(h, "tag_push_events", False), "merge_requests_events": getattr(h, "merge_requests_events", False), "wiki_page_events": getattr(h, "wiki_page_events", False), "issues_events": getattr(h, "issues_events", False), "note_events": getattr(h, "note_events", False), "pipeline_events": getattr(h, "pipeline_events", False), "job_events": getattr(h, "job_events", False), "enable_ssl_verification": getattr(h, "enable_ssl_verification", True), } for h in hooks ] except gitlab.exceptions.GitlabGetError as e: return [{"error": f"Failed to get project hooks: {str(e)}"}] @retry_on_error() def get_pipelines( self, project_id: str, ref: Optional[str] = None, per_page: int = SMALL_PAGE_SIZE ) -> List[Dict[str, Any]]: project = self.gl.projects.get(project_id) kwargs = {"get_all": False, "per_page": per_page} if ref is not None: kwargs["ref"] = ref pipelines = project.pipelines.list(**kwargs) return [ { "id": getattr(p, "id", None), "status": getattr(p, "status", None), "ref": getattr(p, "ref", None), "sha": getattr(p, "sha", None), "created_at": getattr(p, "created_at", None), "updated_at": getattr(p, "updated_at", None), "web_url": getattr(p, "web_url", None), } for p in pipelines ] @retry_on_error() def get_tags(self, project_id: str) -> List[Dict[str, Any]]: """Get list of repository tags. Args: project_id: The ID or path of the project Returns: List of tags """ try: project = self.gl.projects.get(project_id) tags = project.tags.list(get_all=False, per_page=50) return [ { "name": getattr(tag, "name", None), "message": getattr(tag, "message", None), "target": getattr(tag, "target", None), "commit": { "id": getattr(tag.commit, "id", None) if hasattr(tag, "commit") else None, "short_id": getattr(tag.commit, "short_id", None) if hasattr(tag, "commit") else None, "title": getattr(tag.commit, "title", None) if hasattr(tag, "commit") else None, "author_name": getattr(tag.commit, "author_name", None) if hasattr(tag, "commit") else None, "created_at": getattr(tag.commit, "created_at", None) if hasattr(tag, "commit") else None, }, "release": { "tag_name": getattr(tag.release, "tag_name", None) if hasattr(tag, "release") else None, "description": getattr(tag.release, "description", None) if hasattr(tag, "release") else None, } if hasattr(tag, "release") else None, "protected": getattr(tag, "protected", False), } for tag in tags ] except gitlab.exceptions.GitlabGetError as e: return [{"error": f"Failed to get tags: {str(e)}"}] @retry_on_error() def list_releases(self, project_id: str) -> List[Dict[str, Any]]: """Get list of project releases. Args: project_id: The ID or path of the project Returns: List of releases """ try: project = self.gl.projects.get(project_id) releases = project.releases.list(get_all=False, per_page=20) return [ { "tag_name": getattr(release, "tag_name", None), "name": getattr(release, "name", None), "description": getattr(release, "description", None), "created_at": getattr(release, "created_at", None), "released_at": getattr(release, "released_at", None), "author": { "id": getattr(release.author, "id", None) if hasattr(release, "author") else None, "username": getattr(release.author, "username", None) if hasattr(release, "author") else None, "name": getattr(release.author, "name", None) if hasattr(release, "author") else None, } if hasattr(release, "author") else {}, "commit": { "id": getattr(release.commit, "id", None) if hasattr(release, "commit") else None, "short_id": getattr(release.commit, "short_id", None) if hasattr(release, "commit") else None, } if hasattr(release, "commit") else {}, "assets": { "sources": getattr(release.assets, "sources", []) if hasattr(release, "assets") else [], "links": getattr(release.assets, "links", []) if hasattr(release, "assets") else [], } if hasattr(release, "assets") else {}, } for release in releases ] except gitlab.exceptions.GitlabGetError as e: return [{"error": f"Failed to get releases: {str(e)}"}] def get_branches(self, project_id: str) -> List[Dict[str, Any]]: project = self.gl.projects.get(project_id) branches = project.branches.list() return [self._branch_to_dict(b) for b in branches] @retry_on_error() def get_file_content(self, project_id: str, file_path: str, ref: Optional[str] = None) -> Dict[str, Any]: project = self.gl.projects.get(project_id) file_obj = project.files.get(file_path=file_path, ref=ref) content = getattr(file_obj, "content", "") encoding = getattr(file_obj, "encoding", None) if encoding == "base64": try: content = base64.b64decode(content).decode("utf-8") except Exception: # pragma: no cover - defensive content = "" return { "file_path": file_path, "content": content, "size": getattr(file_obj, "size", None), "encoding": encoding, "ref": ref, "last_commit_id": getattr(file_obj, "last_commit_id", None), "blob_id": getattr(file_obj, "blob_id", None), } @retry_on_error() def search_projects( self, search: str, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1 ) -> Dict[str, Any]: kwargs = { "search": search, "get_all": False, "per_page": min(per_page, MAX_PAGE_SIZE), "page": page, } response = self.gl.projects.list(**kwargs) pagination = { "page": page, "per_page": per_page, "total": getattr(response, "total", None), "total_pages": getattr(response, "total_pages", None), "next_page": getattr(response, "next_page", None), "prev_page": getattr(response, "prev_page", None), } return {"projects": [self._project_to_dict(p) for p in response], "pagination": pagination, "search_term": search} @retry_on_error() def get_current_project(self, path: str = ".") -> Optional[Dict[str, Any]]: """Get current project by inspecting git repository""" return self.get_project_from_git(path) def get_project_from_git(self, path: str = ".") -> Optional[Dict[str, Any]]: detected = GitDetector.detect_gitlab_project(path) if not detected: return None if not GitDetector.is_gitlab_url(detected["url"], self.config.url): return None project = self.get_project(detected["path"]) project["git_info"] = { "current_branch": detected.get("branch"), "remote_url": detected["url"], "detected_from": path, } return project @retry_on_error() def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]: users = self.gl.users.list(username=username) if not users: return None user = users[0] return { "id": getattr(user, "id", None), "username": getattr(user, "username", None), "name": getattr(user, "name", None), "state": getattr(user, "state", None), "avatar_url": getattr(user, "avatar_url", None), "web_url": getattr(user, "web_url", None), } @retry_on_error() def get_user_events( self, username: str, action: Optional[str] = None, target_type: Optional[str] = None, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1, after: Optional[str] = None, before: Optional[str] = None, ) -> Dict[str, Any]: user = self.get_user_by_username(username) if not user: return {"events": [], "user": None} user_obj = self.gl.users.get(user["id"]) kwargs = { "get_all": False, "per_page": min(per_page, MAX_PAGE_SIZE), "page": page, } if action: kwargs["action"] = action if target_type: kwargs["target_type"] = target_type if after: kwargs["after"] = after if before: kwargs["before"] = before response = user_obj.events.list(**kwargs) pagination = { "page": page, "per_page": per_page, "total": getattr(response, "total", None), "total_pages": getattr(response, "total_pages", None), "next_page": getattr(response, "next_page", None), "prev_page": getattr(response, "prev_page", None), } events = [self._event_to_dict(e) for e in response] return {"events": events, "user": user, "pagination": pagination} @retry_on_error() def get_current_user(self) -> Dict[str, Any]: """Get the currently authenticated user. Returns: Dictionary with user information including: - id: User ID - username: Username - name: Full name - email: Email address - state: Account state - avatar_url: Avatar URL - web_url: Profile URL - created_at: Account creation date - bio: User bio - organization: Organization - job_title: Job title - public_email: Public email - is_admin: Whether user is admin - can_create_group: Whether user can create groups - can_create_project: Whether user can create projects """ user = self.gl.user # Get extended user info return { "id": getattr(user, "id", None), "username": getattr(user, "username", None), "name": getattr(user, "name", None), "email": getattr(user, "email", None), "state": getattr(user, "state", None), "avatar_url": getattr(user, "avatar_url", None), "web_url": getattr(user, "web_url", None), "created_at": getattr(user, "created_at", None), "bio": getattr(user, "bio", None), "organization": getattr(user, "organization", None), "job_title": getattr(user, "job_title", None), "public_email": getattr(user, "public_email", None), "is_admin": getattr(user, "is_admin", False), "can_create_group": getattr(user, "can_create_group", True), "can_create_project": getattr(user, "can_create_project", True), "two_factor_enabled": getattr(user, "two_factor_enabled", False), "external": getattr(user, "external", False), } @retry_on_error() def get_user(self, user_id: Optional[str] = None, username: Optional[str] = None) -> Optional[Dict[str, Any]]: """Get details for a specific user by ID or username. Args: user_id: User ID (numeric) username: Username (string) Returns: Dictionary with user information or None if not found """ if not user_id and not username: raise ValueError("Either user_id or username must be provided") if user_id: try: user = self.gl.users.get(user_id) return { "id": getattr(user, "id", None), "username": getattr(user, "username", None), "name": getattr(user, "name", None), "state": getattr(user, "state", None), "avatar_url": getattr(user, "avatar_url", None), "web_url": getattr(user, "web_url", None), "created_at": getattr(user, "created_at", None), "bio": getattr(user, "bio", None), "organization": getattr(user, "organization", None), "job_title": getattr(user, "job_title", None), "public_email": getattr(user, "public_email", None), "external": getattr(user, "external", False), } except gitlab.exceptions.GitlabGetError: return None else: # Search by username return self.get_user_by_username(username) @retry_on_error() def list_groups( self, search: Optional[str] = None, owned: bool = False, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1, ) -> Dict[str, Any]: """List accessible groups. Args: search: Search term for group names/paths owned: Only show groups user owns per_page: Number of results per page page: Page number Returns: Dictionary with groups list and pagination info """ kwargs = { "get_all": False, "per_page": min(per_page, MAX_PAGE_SIZE), "page": page, } if search: kwargs["search"] = search if owned: kwargs["owned"] = True response = self.gl.groups.list(**kwargs) pagination = { "page": page, "per_page": per_page, "total": getattr(response, "total", None), "total_pages": getattr(response, "total_pages", None), "next_page": getattr(response, "next_page", None), "prev_page": getattr(response, "prev_page", None), } groups = [] for group in response: groups.append({ "id": getattr(group, "id", None), "name": getattr(group, "name", None), "path": getattr(group, "path", None), "full_path": getattr(group, "full_path", None), "description": getattr(group, "description", None), "visibility": getattr(group, "visibility", None), "web_url": getattr(group, "web_url", None), "avatar_url": getattr(group, "avatar_url", None), "parent_id": getattr(group, "parent_id", None), }) return {"groups": groups, "pagination": pagination} @retry_on_error() def get_group(self, group_id: str, with_projects: bool = False) -> Dict[str, Any]: """Get group details. Args: group_id: Group ID or path with_projects: Include projects in response Returns: Dictionary with group information """ group = self.gl.groups.get(group_id) result = { "id": getattr(group, "id", None), "name": getattr(group, "name", None), "path": getattr(group, "path", None), "full_path": getattr(group, "full_path", None), "description": getattr(group, "description", None), "visibility": getattr(group, "visibility", None), "web_url": getattr(group, "web_url", None), "avatar_url": getattr(group, "avatar_url", None), "parent_id": getattr(group, "parent_id", None), "created_at": getattr(group, "created_at", None), "lfs_enabled": getattr(group, "lfs_enabled", False), "request_access_enabled": getattr(group, "request_access_enabled", True), "full_name": getattr(group, "full_name", None), "projects_count": getattr(group, "statistics", {}).get("projects", 0) if hasattr(group, "statistics") else 0, } if with_projects: # Get first page of projects projects_response = self.list_group_projects(group_id, per_page=20, page=1) result["projects"] = projects_response.get("projects", []) result["projects_pagination"] = projects_response.get("pagination", {}) return result @retry_on_error() def list_group_projects( self, group_id: str, search: Optional[str] = None, include_subgroups: bool = False, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1, ) -> Dict[str, Any]: """List projects within a group. Args: group_id: Group ID or path search: Search term for project names include_subgroups: Include projects from subgroups per_page: Number of results per page page: Page number Returns: Dictionary with projects list and pagination info """ group = self.gl.groups.get(group_id) kwargs = { "get_all": False, "per_page": min(per_page, MAX_PAGE_SIZE), "page": page, "include_subgroups": include_subgroups, } if search: kwargs["search"] = search response = group.projects.list(**kwargs) pagination = { "page": page, "per_page": per_page, "total": getattr(response, "total", None), "total_pages": getattr(response, "total_pages", None), "next_page": getattr(response, "next_page", None), "prev_page": getattr(response, "prev_page", None), } return { "projects": [self._project_to_dict(p) for p in response], "pagination": pagination, "group_id": group_id, } def _snippet_to_dict(self, snippet: Any) -> Dict[str, Any]: """Convert snippet object to dictionary""" return { "id": getattr(snippet, "id", None), "title": getattr(snippet, "title", None), "file_name": getattr(snippet, "file_name", None), "description": getattr(snippet, "description", None), "visibility": getattr(snippet, "visibility", None), "author": getattr(snippet, "author", None), "created_at": getattr(snippet, "created_at", None), "updated_at": getattr(snippet, "updated_at", None), "web_url": getattr(snippet, "web_url", None), "raw_url": getattr(snippet, "raw_url", None), } @retry_on_error() def list_snippets( self, project_id: str, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1, ) -> Dict[str, Any]: """List project snippets. Args: project_id: The project ID or path per_page: Number of results per page page: Page number Returns: Dictionary with snippets list and pagination info """ project = self.gl.projects.get(project_id) kwargs = { "get_all": False, "per_page": min(per_page, MAX_PAGE_SIZE), "page": page, } response = project.snippets.list(**kwargs) pagination = { "page": page, "per_page": per_page, "total": getattr(response, "total", None), "total_pages": getattr(response, "total_pages", None), "next_page": getattr(response, "next_page", None), "prev_page": getattr(response, "prev_page", None), } return { "snippets": [self._snippet_to_dict(s) for s in response], "pagination": pagination, "project_id": project_id, } @retry_on_error() def get_snippet(self, project_id: str, snippet_id: int) -> Dict[str, Any]: """Get a specific snippet with content. Args: project_id: The project ID or path snippet_id: The snippet ID Returns: Dictionary with snippet details and content """ project = self.gl.projects.get(project_id) snippet = project.snippets.get(snippet_id) result = self._snippet_to_dict(snippet) result["content"] = getattr(snippet, "content", "") return result @retry_on_error() def create_snippet( self, project_id: str, title: str, file_name: str, content: str, description: Optional[str] = None, visibility: str = "private" ) -> Dict[str, Any]: """Create a new snippet. Args: project_id: The project ID or path title: Snippet title file_name: File name for the snippet content: Snippet content description: Optional description visibility: Snippet visibility (private, internal, public) Returns: Dictionary with created snippet details """ project = self.gl.projects.get(project_id) snippet_data = { "title": title, "file_name": file_name, "content": content, "visibility": visibility, } if description: snippet_data["description"] = description snippet = project.snippets.create(snippet_data) return self._snippet_to_dict(snippet) @retry_on_error() def update_snippet( self, project_id: str, snippet_id: int, title: Optional[str] = None, file_name: Optional[str] = None, content: Optional[str] = None, description: Optional[str] = None, visibility: Optional[str] = None ) -> Dict[str, Any]: """Update an existing snippet. Args: project_id: The project ID or path snippet_id: The snippet ID title: Optional new title file_name: Optional new file name content: Optional new content description: Optional new description visibility: Optional new visibility Returns: Dictionary with updated snippet details """ project = self.gl.projects.get(project_id) snippet = project.snippets.get(snippet_id, lazy=False) update_data = {} if title is not None: update_data["title"] = title if file_name is not None: update_data["file_name"] = file_name if content is not None: update_data["content"] = content if description is not None: update_data["description"] = description if visibility is not None: update_data["visibility"] = visibility if update_data: for key, value in update_data.items(): setattr(snippet, key, value) snippet.save() return self._snippet_to_dict(snippet) @staticmethod def _job_to_dict(job: Any) -> Dict[str, Any]: """Convert job object to dictionary""" return { "id": getattr(job, "id", None), "name": getattr(job, "name", None), "stage": getattr(job, "stage", None), "status": getattr(job, "status", None), "created_at": getattr(job, "created_at", None), "started_at": getattr(job, "started_at", None), "finished_at": getattr(job, "finished_at", None), "duration": getattr(job, "duration", None), "user": getattr(job, "user", None), "commit": getattr(job, "commit", None), "pipeline": getattr(job, "pipeline", None), "web_url": getattr(job, "web_url", None), "artifacts": getattr(job, "artifacts", []), "artifacts_expire_at": getattr(job, "artifacts_expire_at", None), "tag_list": getattr(job, "tag_list", []), "runner": getattr(job, "runner", None), } @retry_on_error() def list_pipeline_jobs( self, project_id: str, pipeline_id: int, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1, ) -> Dict[str, Any]: """List jobs in a specific pipeline. Args: project_id: The project ID or path pipeline_id: The pipeline ID per_page: Number of results per page page: Page number Returns: Dictionary with jobs list and pagination info """ project = self.gl.projects.get(project_id) pipeline = project.pipelines.get(pipeline_id) kwargs = { "get_all": False, "per_page": min(per_page, MAX_PAGE_SIZE), "page": page, } response = pipeline.jobs.list(**kwargs) pagination = { "page": page, "per_page": per_page, "total": getattr(response, "total", None), "total_pages": getattr(response, "total_pages", None), "next_page": getattr(response, "next_page", None), "prev_page": getattr(response, "prev_page", None), } return { "jobs": [self._job_to_dict(j) for j in response], "pagination": pagination, "project_id": project_id, "pipeline_id": pipeline_id, } @retry_on_error() def download_job_artifact( self, project_id: str, job_id: int, artifact_path: Optional[str] = None, ) -> Dict[str, Any]: """Download job artifacts. Args: project_id: The project ID or path job_id: The job ID artifact_path: Optional specific artifact path to download Returns: Dictionary with artifact information and download details """ project = self.gl.projects.get(project_id) job = project.jobs.get(job_id) # Get job artifacts info artifacts_info = [] if hasattr(job, "artifacts") and job.artifacts: for artifact in job.artifacts: artifacts_info.append({ "filename": getattr(artifact, "filename", None), "size": getattr(artifact, "size", None), }) # For security reasons, we don't actually download the artifact content # but return information about available artifacts result = { "job_id": job_id, "job_name": getattr(job, "name", None), "project_id": project_id, "artifacts": artifacts_info, "artifacts_expire_at": getattr(job, "artifacts_expire_at", None), "download_note": "Artifact content not downloaded for security reasons. Use GitLab web interface or CLI for actual downloads.", } if artifact_path: result["requested_path"] = artifact_path return result @retry_on_error() def list_project_jobs( self, project_id: str, scope: Optional[str] = None, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1, ) -> Dict[str, Any]: """List jobs for a project. Args: project_id: The project ID or path scope: Optional scope filter (created, pending, running, failed, success, canceled, skipped, waiting_for_resource, manual) per_page: Number of results per page page: Page number Returns: Dictionary with jobs list and pagination info """ project = self.gl.projects.get(project_id) kwargs = { "get_all": False, "per_page": min(per_page, MAX_PAGE_SIZE), "page": page, } if scope: kwargs["scope"] = scope response = project.jobs.list(**kwargs) pagination = { "page": page, "per_page": per_page, "total": getattr(response, "total", None), "total_pages": getattr(response, "total_pages", None), "next_page": getattr(response, "next_page", None), "prev_page": getattr(response, "prev_page", None), } return { "jobs": [self._job_to_dict(j) for j in response], "pagination": pagination, "project_id": project_id, "scope": scope, } # ------------------------------------------------------------------ # Commit and Diff Methods # ------------------------------------------------------------------ @retry_on_error() def get_commits(self, project_id: str, ref_name: Optional[str] = None, since: Optional[str] = None, until: Optional[str] = None, path: Optional[str] = None, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1) -> Dict[str, Any]: """Get list of commits for a project. Args: project_id: The ID or path of the project ref_name: The name of a repository branch, tag or revision range since: Only commits after this date (ISO 8601 format) until: Only commits before this date (ISO 8601 format) path: The file path to filter commits by per_page: Number of items per page (max 100) page: Page number for pagination Returns: Dict containing commits list and pagination info """ try: project = self.gl.projects.get(project_id) commits = project.commits.list( ref_name=ref_name, since=since, until=until, path=path, per_page=per_page, page=page, get_all=False ) pagination = { "page": page, "per_page": per_page, "total": getattr(commits, "total", None), "total_pages": getattr(commits, "total_pages", None), "next_page": getattr(commits, "next_page", None), "prev_page": getattr(commits, "prev_page", None), } return { "commits": [ { "id": getattr(commit, "id", None), "short_id": getattr(commit, "short_id", None), "title": getattr(commit, "title", None), "message": getattr(commit, "message", None), "author_name": getattr(commit, "author_name", None), "author_email": getattr(commit, "author_email", None), "authored_date": getattr(commit, "authored_date", None), "committer_name": getattr(commit, "committer_name", None), "committer_email": getattr(commit, "committer_email", None), "committed_date": getattr(commit, "committed_date", None), "created_at": getattr(commit, "created_at", None), "parent_ids": getattr(commit, "parent_ids", None), "web_url": getattr(commit, "web_url", None) } for commit in commits ], "pagination": pagination } except gitlab.exceptions.GitlabGetError as e: return {"error": f"Failed to get commits: {str(e)}"} @retry_on_error() def get_commit(self, project_id: str, commit_sha: str, include_stats: bool = False) -> Dict[str, Any]: """Get details of a specific commit. Args: project_id: The ID or path of the project commit_sha: The commit hash include_stats: Include commit stats in response Returns: Dict containing commit details """ try: project = self.gl.projects.get(project_id) commit = project.commits.get(commit_sha, stats=include_stats) result = { "id": getattr(commit, "id", None), "short_id": getattr(commit, "short_id", None), "title": getattr(commit, "title", None), "message": getattr(commit, "message", None), "author_name": getattr(commit, "author_name", None), "author_email": getattr(commit, "author_email", None), "authored_date": getattr(commit, "authored_date", None), "committer_name": getattr(commit, "committer_name", None), "committer_email": getattr(commit, "committer_email", None), "committed_date": getattr(commit, "committed_date", None), "created_at": getattr(commit, "created_at", None), "parent_ids": getattr(commit, "parent_ids", None), "web_url": getattr(commit, "web_url", None) } if include_stats and hasattr(commit, 'stats'): result["stats"] = commit.stats return result except gitlab.exceptions.GitlabGetError as e: return {"error": f"Failed to get commit: {str(e)}"} @retry_on_error() def get_commit_diff(self, project_id: str, commit_sha: str) -> Dict[str, Any]: """Get the diff of a specific commit. Args: project_id: The ID or path of the project commit_sha: The commit hash Returns: Dict containing commit SHA and diff """ try: project = self.gl.projects.get(project_id) commit = project.commits.get(commit_sha) diff = commit.diff() return { "commit_sha": commit_sha, "diff": diff } except gitlab.exceptions.GitlabGetError as e: return {"error": f"Failed to get commit diff: {str(e)}"} @retry_on_error() def create_commit(self, project_id: str, branch: str, commit_message: str, actions: List[Dict[str, Any]], author_email: Optional[str] = None, author_name: Optional[str] = None) -> Dict[str, Any]: """Create a new commit with file changes. Args: project_id: The ID or path of the project branch: Name of the branch to commit to commit_message: The commit message actions: List of file actions (create/update/delete/move/chmod) author_email: The commit author's email author_name: The commit author's name Returns: Dict containing created commit details """ try: # Validate actions VALID_ACTIONS = ["create", "update", "delete", "move", "chmod"] for action in actions: action_type = action.get("action") if action_type not in VALID_ACTIONS: return {"error": f"Invalid action type: {action_type}. Must be one of {VALID_ACTIONS}"} if not action.get("file_path"): return {"error": "Each action must have a 'file_path'"} project = self.gl.projects.get(project_id) commit_data = { "branch": branch, "commit_message": commit_message, "actions": actions } if author_email: commit_data["author_email"] = author_email if author_name: commit_data["author_name"] = author_name commit = project.commits.create(commit_data) return { "id": getattr(commit, "id", None), "short_id": getattr(commit, "short_id", None), "title": getattr(commit, "title", None), "message": getattr(commit, "message", None), "author_name": getattr(commit, "author_name", None), "author_email": getattr(commit, "author_email", None), "created_at": getattr(commit, "created_at", None), "parent_ids": getattr(commit, "parent_ids", None), "web_url": getattr(commit, "web_url", None) } except gitlab.exceptions.GitlabCreateError as e: return {"error": f"Failed to create commit: {str(e)}"} @retry_on_error() def compare_refs(self, project_id: str, from_ref: str, to_ref: str, straight: bool = False) -> Dict[str, Any]: """Compare two refs (branches, tags, or commits). Args: project_id: The ID or path of the project from_ref: The source ref to_ref: The target ref straight: Compare refs in a straight line (not merge base) Returns: Dict containing commits and diffs between refs """ try: project = self.gl.projects.get(project_id) comparison = project.repository_compare(from_ref, to_ref, straight=straight) return { "commits": [ { "id": commit["id"], "short_id": commit.get("short_id"), "title": commit.get("title"), "message": commit.get("message"), "author_name": commit.get("author_name"), "created_at": commit.get("created_at") } for commit in comparison.get("commits", []) ], "diffs": comparison.get("diffs", []), "compare_timeout": comparison.get("compare_timeout", False), "compare_same_ref": comparison.get("compare_same_ref", False) } except gitlab.exceptions.GitlabGetError as e: return {"error": f"Failed to compare refs: {str(e)}"} except Exception as e: return {"error": f"Unexpected error comparing refs: {str(e)}"} @retry_on_error() def cherry_pick_commit(self, project_id: str, commit_sha: str, branch: str) -> Dict[str, Any]: """Cherry pick a commit to another branch. Args: project_id: The ID or path of the project commit_sha: The commit hash to cherry pick branch: The target branch name Returns: Dict containing cherry picked commit details """ try: project = self.gl.projects.get(project_id) commit = project.commits.get(commit_sha) cherry_picked = commit.cherry_pick(branch) return { "id": cherry_picked.get("id"), "short_id": cherry_picked.get("short_id"), "title": cherry_picked.get("title"), "message": cherry_picked.get("message"), "author_name": cherry_picked.get("author_name"), "created_at": cherry_picked.get("created_at"), "parent_ids": cherry_picked.get("parent_ids"), "web_url": cherry_picked.get("web_url") } except gitlab.exceptions.GitlabError as e: return {"error": f"Failed to cherry pick commit: {str(e)}"} except Exception as e: return {"error": f"Unexpected error cherry picking commit: {str(e)}"} @retry_on_error() def smart_diff(self, project_id: str, from_ref: str, to_ref: str, context_lines: int = 3, max_file_size: int = 100000) -> Dict[str, Any]: """Get a smart diff between two refs with configurable context. Intelligently handles large files by checking byte size rather than line count. Args: project_id: The ID or path of the project from_ref: The source ref to_ref: The target ref context_lines: Number of context lines in diff max_file_size: Maximum file size in bytes (default 100KB) Returns: Dict containing diffs and commits between refs """ try: project = self.gl.projects.get(project_id) comparison = project.repository_compare(from_ref, to_ref) diffs = [] for diff in comparison.get("diffs", []): diff_content = diff.get("diff", "") # Check byte size instead of line count for more accurate size check if len(diff_content) > max_file_size: diffs.append({ "old_path": diff.get("old_path"), "new_path": diff.get("new_path"), "diff": f"File too large (>{max_file_size} bytes)", "new_file": diff.get("new_file", False), "renamed_file": diff.get("renamed_file", False), "deleted_file": diff.get("deleted_file", False) }) else: diffs.append({ "old_path": diff.get("old_path"), "new_path": diff.get("new_path"), "diff": diff.get("diff"), "new_file": diff.get("new_file", False), "renamed_file": diff.get("renamed_file", False), "deleted_file": diff.get("deleted_file", False), "a_mode": diff.get("a_mode"), "b_mode": diff.get("b_mode") }) return { "from_ref": from_ref, "to_ref": to_ref, "context_lines": context_lines, "diffs": diffs, "commits": [ { "id": commit["id"], "short_id": commit.get("short_id"), "title": commit.get("title"), "author_name": commit.get("author_name") } for commit in comparison.get("commits", []) ] } except gitlab.exceptions.GitlabGetError as e: return {"error": f"Failed to get smart diff: {str(e)}"} except Exception as e: return {"error": f"Unexpected error getting smart diff: {str(e)}"} @retry_on_error() def safe_preview_commit(self, project_id: str, branch: str, commit_message: str, actions: List[Dict[str, Any]]) -> Dict[str, Any]: """Preview what a commit would do without actually creating it. Validates that all actions are valid before attempting to commit. Args: project_id: The ID or path of the project branch: Name of the branch to preview commit on commit_message: The commit message actions: List of file actions to validate Returns: Dict containing preview results and validation status """ try: # Validate action types first VALID_ACTIONS = ["create", "update", "delete", "move", "chmod"] for action in actions: action_type = action.get("action") if action_type not in VALID_ACTIONS: return { "error": f"Invalid action type: {action_type}. Must be one of {VALID_ACTIONS}", "valid": False } if not action.get("file_path"): return { "error": "Each action must have a 'file_path'", "valid": False } project = self.gl.projects.get(project_id) # Validate branch exists try: project.branches.get(branch) except gitlab.exceptions.GitlabGetError: return {"error": f"Branch '{branch}' does not exist"} # Validate actions preview_results = [] for action in actions: action_type = action.get("action") file_path = action.get("file_path") result = { "action": action_type, "file_path": file_path, "valid": True, "message": "OK" } # Check if file exists for update/delete actions if action_type in ["update", "delete"]: try: project.files.get(file_path, ref=branch) except gitlab.exceptions.GitlabGetError: result["valid"] = False result["message"] = f"File '{file_path}' does not exist" # Check if file doesn't exist for create actions elif action_type == "create": try: project.files.get(file_path, ref=branch) result["valid"] = False result["message"] = f"File '{file_path}' already exists" except gitlab.exceptions.GitlabGetError: pass # File doesn't exist, which is expected preview_results.append(result) return { "branch": branch, "commit_message": commit_message, "preview_results": preview_results, "valid": all(r["valid"] for r in preview_results) } except gitlab.exceptions.GitlabGetError as e: return {"error": f"Failed to preview commit: {str(e)}"} except Exception as e: return {"error": f"Unexpected error previewing commit: {str(e)}"} @retry_on_error() def summarize_pipeline(self, project_id: str, pipeline_id: int) -> Dict[str, Any]: """Generate an AI-friendly summary of a pipeline. Args: project_id: The ID or path of the project pipeline_id: The ID of the pipeline Returns: Dict containing structured pipeline summary """ try: project = self.gl.projects.get(project_id) pipeline = project.pipelines.get(pipeline_id) # Get jobs for this pipeline jobs = [] try: pipeline_jobs = pipeline.jobs.list(get_all=True) jobs = [ { "id": getattr(job, "id", None), "name": getattr(job, "name", None), "stage": getattr(job, "stage", None), "status": getattr(job, "status", None), "started_at": getattr(job, "started_at", None), "finished_at": getattr(job, "finished_at", None), "duration": getattr(job, "duration", None), "web_url": getattr(job, "web_url", None), } for job in pipeline_jobs[:20] # Limit to first 20 jobs ] except Exception: jobs = [] # Calculate pipeline stats total_jobs = len(jobs) passed_jobs = len([j for j in jobs if j.get("status") == "success"]) failed_jobs = len([j for j in jobs if j.get("status") == "failed"]) running_jobs = len([j for j in jobs if j.get("status") in ["running", "pending"]]) return { "pipeline_id": pipeline_id, "status": getattr(pipeline, "status", None), "ref": getattr(pipeline, "ref", None), "sha": getattr(pipeline, "sha", None), "created_at": getattr(pipeline, "created_at", None), "updated_at": getattr(pipeline, "updated_at", None), "started_at": getattr(pipeline, "started_at", None), "finished_at": getattr(pipeline, "finished_at", None), "duration": getattr(pipeline, "duration", None), "web_url": getattr(pipeline, "web_url", None), "jobs_summary": { "total": total_jobs, "passed": passed_jobs, "failed": failed_jobs, "running": running_jobs, }, "stages": list(set(j.get("stage") for j in jobs if j.get("stage"))), "jobs": jobs, "user": { "username": getattr(pipeline.user, "username", None) if hasattr(pipeline, "user") else None, "name": getattr(pipeline.user, "name", None) if hasattr(pipeline, "user") else None, } if hasattr(pipeline, "user") else {}, } except gitlab.exceptions.GitlabGetError as e: return {"error": f"Failed to get pipeline: {str(e)}"} def summarize_issue(self, project_id: str, issue_iid: int, max_length: int = 500) -> Dict[str, Any]: """Generate an AI-friendly summary of an issue. This method retrieves issue details and comments, then formats them into a concise summary suitable for LLM context. Args: project_id: The project ID or path issue_iid: The issue IID (internal ID) max_length: Maximum length for description and comment summaries Returns: Dictionary with summarized issue information """ # Get issue details issue = self.get_issue(project_id, issue_iid) # Get issue notes (comments) project = self.gl.projects.get(project_id) issue_obj = project.issues.get(issue_iid) # Get all notes with pagination all_notes = [] kwargs = { "get_all": False, "per_page": SMALL_PAGE_SIZE, "page": 1, "order_by": "created_at", "sort": "asc" } # Get first page to check if there are any notes response = issue_obj.notes.list(**kwargs) all_notes.extend(response) # Get remaining pages if needed total_pages = getattr(response, "total_pages", 1) for page in range(2, min(total_pages + 1, 5)): # Limit to 5 pages max kwargs["page"] = page response = issue_obj.notes.list(**kwargs) all_notes.extend(response) # Convert notes to dict format notes = [self._note_to_dict(n, max_length) for n in all_notes] # Filter out system notes user_comments = [n for n in notes if not n.get("system", False)] # Truncate description if needed description = issue.get("description", "") truncated_description = False if len(description) > max_length: description = description[:max_length] + "... [truncated]" truncated_description = True # Create summary summary = { "issue": { "iid": issue["iid"], "title": issue["title"], "state": issue["state"], "created_at": issue["created_at"], "updated_at": issue["updated_at"], "labels": issue.get("labels", []), "author": issue.get("author"), "web_url": issue["web_url"] }, "description": description, "comments_count": len(user_comments), "comments": user_comments[:10], # Limit to 10 most recent comments "summary_info": { "total_comments": len(all_notes), "user_comments": len(user_comments), "truncated_description": truncated_description, "truncated_comments": len(user_comments) > 10 } } return summary # ============================================================================ # USER & PROFILE METHODS # ============================================================================ @retry_on_error() def search_user(self, search: str, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1) -> Dict[str, Any]: """Search for GitLab users by name, username, or email""" kwargs = { "search": search, "get_all": False, "per_page": min(per_page, MAX_PAGE_SIZE), "page": page, } response = self.gl.users.list(**kwargs) pagination = { "page": page, "per_page": per_page, "total": getattr(response, "total", None), "total_pages": getattr(response, "total_pages", None), "next_page": getattr(response, "next_page", None), "prev_page": getattr(response, "prev_page", None), } return { "users": [self._user_to_dict(u) for u in response], "pagination": pagination, "search_term": search, } @staticmethod def _user_to_dict(user: Any) -> Dict[str, Any]: """Convert user object to dictionary""" return { "id": getattr(user, "id", None), "username": getattr(user, "username", None), "name": getattr(user, "name", None), "email": getattr(user, "email", None), "avatar_url": getattr(user, "avatar_url", None), "web_url": getattr(user, "web_url", None), "state": getattr(user, "state", None), "bio": getattr(user, "bio", None), "location": getattr(user, "location", None), "public_email": getattr(user, "public_email", None), "skype": getattr(user, "skype", None), "linkedin": getattr(user, "linkedin", None), "twitter": getattr(user, "twitter", None), "website_url": getattr(user, "website_url", None), "organization": getattr(user, "organization", None), "job_title": getattr(user, "job_title", None), "created_at": getattr(user, "created_at", None), "last_sign_in_at": getattr(user, "last_sign_in_at", None), "is_admin": getattr(user, "is_admin", False), "can_create_group": getattr(user, "can_create_group", False), "can_create_project": getattr(user, "can_create_project", False), "projects_limit": getattr(user, "projects_limit", None), } @retry_on_error() def get_user_details(self, user_id: Optional[str] = None, username: Optional[str] = None) -> Dict[str, Any]: """Get comprehensive user profile and metadata""" if user_id: user = self.gl.users.get(user_id, lazy=False) elif username: users = self.gl.users.list(username=username) if not users: raise ValueError(f"User not found: {username}") user = users[0] else: raise ValueError("Either user_id or username must be provided") return self._user_to_dict(user) @retry_on_error() def get_my_profile(self) -> Dict[str, Any]: """Get the current authenticated user's complete profile""" user = self.gl.user return self._user_to_dict(user) @retry_on_error() def get_user_contributions_summary( self, user_id: Optional[str] = None, username: Optional[str] = None, since: Optional[str] = None, until: Optional[str] = None, project_id: Optional[str] = None ) -> Dict[str, Any]: """Summarize user's recent contributions across issues, MRs, and commits""" # Get user info first if user_id: user = self.gl.users.get(user_id, lazy=False) elif username: users = self.gl.users.list(username=username) if not users: raise ValueError(f"User not found: {username}") user = users[0] else: raise ValueError("Either user_id or username must be provided") user_info = self._user_to_dict(user) # Get user events to build contribution summary events_kwargs = {"get_all": False, "per_page": 100} if since: events_kwargs["after"] = since if until: events_kwargs["before"] = until events = user.events.list(**events_kwargs) # Analyze events to build summary commit_count = len([e for e in events if getattr(e, "action_name", "") == "pushed"]) issue_created = len([e for e in events if getattr(e, "action_name", "") == "created" and getattr(e, "target_type", "") == "Issue"]) issue_closed = len([e for e in events if getattr(e, "action_name", "") == "closed" and getattr(e, "target_type", "") == "Issue"]) mr_created = len([e for e in events if getattr(e, "action_name", "") == "created" and getattr(e, "target_type", "") == "MergeRequest"]) mr_merged = len([e for e in events if getattr(e, "action_name", "") == "merged" and getattr(e, "target_type", "") == "MergeRequest"]) return { "user": user_info, "period": { "since": since, "until": until, }, "contributions": { "commits": {"count": commit_count}, "issues": { "created": issue_created, "closed": issue_closed, }, "merge_requests": { "created": mr_created, "merged": mr_merged, } }, "activity_summary": { "total_events": len(events), "active_period": since and until, "project_scope": project_id, } } @retry_on_error() def get_user_activity_feed( self, user_id: Optional[str] = None, username: Optional[str] = None, action: Optional[str] = None, target_type: Optional[str] = None, after: Optional[str] = None, before: Optional[str] = None, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1 ) -> Dict[str, Any]: """Retrieve user's complete activity/events timeline""" # Get user info first if user_id: user = self.gl.users.get(user_id, lazy=False) elif username: users = self.gl.users.list(username=username) if not users: raise ValueError(f"User not found: {username}") user = users[0] else: raise ValueError("Either user_id or username must be provided") # Build events query kwargs = { "get_all": False, "per_page": min(per_page, MAX_PAGE_SIZE), "page": page, } if action: kwargs["action"] = action if target_type: kwargs["target_type"] = target_type if after: kwargs["after"] = after if before: kwargs["before"] = before events = user.events.list(**kwargs) pagination = { "page": page, "per_page": per_page, "total": getattr(events, "total", None), "total_pages": getattr(events, "total_pages", None), "next_page": getattr(events, "next_page", None), "prev_page": getattr(events, "prev_page", None), } return { "user": self._user_to_dict(user), "events": [self._event_to_dict(e) for e in events], "pagination": pagination, "filters": { "action": action, "target_type": target_type, "after": after, "before": before, } } # ============================================================================ # USER'S ISSUES & MRS METHODS # ============================================================================ @retry_on_error() def get_user_open_mrs( self, user_id: Optional[str] = None, username: Optional[str] = None, sort: str = "updated", per_page: int = DEFAULT_PAGE_SIZE, page: int = 1 ) -> Dict[str, Any]: """Get all open merge requests authored by a user""" # Get user info first if user_id: user = self.gl.users.get(user_id, lazy=False) elif username: users = self.gl.users.list(username=username) if not users: raise ValueError(f"User not found: {username}") user = users[0] else: raise ValueError("Either user_id or username must be provided") # Search for open MRs by this user across all accessible projects kwargs = { "state": "opened", "author_id": user.id, "get_all": False, "per_page": min(per_page, MAX_PAGE_SIZE), "page": page, "order_by": "updated_at" if sort == "updated" else "created_at", "sort": "desc" } # This is a simplified implementation - in real GitLab API, you'd search across projects # For the mock, we'll return user's open MRs from their accessible projects mrs = [] user_info = self._user_to_dict(user) pagination = { "page": page, "per_page": per_page, "total": len(mrs), "total_pages": 1, "next_page": None, "prev_page": None, } return { "user": user_info, "merge_requests": mrs, "pagination": pagination, "sort": sort, } @retry_on_error() def get_user_review_requests( self, user_id: Optional[str] = None, username: Optional[str] = None, priority: Optional[str] = None, sort: str = "urgency", per_page: int = DEFAULT_PAGE_SIZE, page: int = 1 ) -> Dict[str, Any]: """Get MRs where user is assigned as reviewer with pending action""" # Get user info first if user_id: user = self.gl.users.get(user_id, lazy=False) elif username: users = self.gl.users.list(username=username) if not users: raise ValueError(f"User not found: {username}") user = users[0] else: raise ValueError("Either user_id or username must be provided") # In a real implementation, this would search for MRs where user is assigned as reviewer review_requests = [] user_info = self._user_to_dict(user) pagination = { "page": page, "per_page": per_page, "total": len(review_requests), "total_pages": 1, "next_page": None, "prev_page": None, } return { "user": user_info, "review_requests": review_requests, "pagination": pagination, "filters": { "priority": priority, "sort": sort, } } @retry_on_error() def get_user_open_issues( self, user_id: Optional[str] = None, username: Optional[str] = None, severity: Optional[str] = None, sla_status: Optional[str] = None, sort: str = "priority", per_page: int = DEFAULT_PAGE_SIZE, page: int = 1 ) -> Dict[str, Any]: """Get open issues assigned to a user, prioritized by severity/SLA""" # Get user info first if user_id: user = self.gl.users.get(user_id, lazy=False) elif username: users = self.gl.users.list(username=username) if not users: raise ValueError(f"User not found: {username}") user = users[0] else: raise ValueError("Either user_id or username must be provided") # In a real implementation, this would search for open issues assigned to user issues = [] user_info = self._user_to_dict(user) pagination = { "page": page, "per_page": per_page, "total": len(issues), "total_pages": 1, "next_page": None, "prev_page": None, } return { "user": user_info, "issues": issues, "pagination": pagination, "filters": { "severity": severity, "sla_status": sla_status, "sort": sort, } } @retry_on_error() def get_user_reported_issues( self, user_id: Optional[str] = None, username: Optional[str] = None, state: str = "opened", since: Optional[str] = None, until: Optional[str] = None, sort: str = "created", per_page: int = DEFAULT_PAGE_SIZE, page: int = 1 ) -> Dict[str, Any]: """Get issues reported/created by a user""" # Get user info first if user_id: user = self.gl.users.get(user_id, lazy=False) elif username: users = self.gl.users.list(username=username) if not users: raise ValueError(f"User not found: {username}") user = users[0] else: raise ValueError("Either user_id or username must be provided") # In a real implementation, this would search for issues created by user issues = [] user_info = self._user_to_dict(user) pagination = { "page": page, "per_page": per_page, "total": len(issues), "total_pages": 1, "next_page": None, "prev_page": None, } return { "user": user_info, "issues": issues, "pagination": pagination, "filters": { "state": state, "since": since, "until": until, "sort": sort, } } @retry_on_error() def get_user_resolved_issues( self, user_id: Optional[str] = None, username: Optional[str] = None, since: Optional[str] = None, until: Optional[str] = None, complexity: Optional[str] = None, sort: str = "closed", per_page: int = DEFAULT_PAGE_SIZE, page: int = 1 ) -> Dict[str, Any]: """Get issues closed/resolved by a user""" # Get user info first if user_id: user = self.gl.users.get(user_id, lazy=False) elif username: users = self.gl.users.list(username=username) if not users: raise ValueError(f"User not found: {username}") user = users[0] else: raise ValueError("Either user_id or username must be provided") # In a real implementation, this would search for issues closed by user issues = [] user_info = self._user_to_dict(user) pagination = { "page": page, "per_page": per_page, "total": len(issues), "total_pages": 1, "next_page": None, "prev_page": None, } return { "user": user_info, "issues": issues, "pagination": pagination, "filters": { "since": since, "until": until, "complexity": complexity, "sort": sort, } } # ============================================================================ # USER'S CODE & COMMITS METHODS # ============================================================================ @retry_on_error() def get_user_commits( self, user_id: Optional[str] = None, username: Optional[str] = None, project_id: Optional[str] = None, branch: Optional[str] = None, since: Optional[str] = None, until: Optional[str] = None, include_stats: bool = False, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1 ) -> Dict[str, Any]: """Get commits authored by a user within date range or branch""" # Get user info first if user_id: user = self.gl.users.get(user_id, lazy=False) elif username: users = self.gl.users.list(username=username) if not users: raise ValueError(f"User not found: {username}") user = users[0] else: raise ValueError("Either user_id or username must be provided") # In a real implementation, this would search commits by author across projects commits = [] user_info = self._user_to_dict(user) pagination = { "page": page, "per_page": per_page, "total": len(commits), "total_pages": 1, "next_page": None, "prev_page": None, } return { "user": user_info, "commits": commits, "pagination": pagination, "filters": { "project_id": project_id, "branch": branch, "since": since, "until": until, "include_stats": include_stats, } } @retry_on_error() def get_user_merge_commits(self, username: str, project_id: Optional[str] = None, since: Optional[str] = None, until: Optional[str] = None, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1) -> Dict[str, Any]: """Get merge commits authored by a user""" if project_id: projects = [self._get_project(project_id)] else: # Get all accessible projects (limited search) projects = self.gl.projects.list(membership=True, get_all=False, per_page=20) all_merge_commits = [] for project in projects: try: # Get commits by user kwargs = { "author": username, "get_all": False, "per_page": min(per_page, MAX_PAGE_SIZE), "page": page, } if since: kwargs["since"] = since if until: kwargs["until"] = until response = project.commits.list(**kwargs) # Filter for merge commits only for commit in response: try: commit_detail = project.commits.get(getattr(commit, "id", None)) parent_ids = getattr(commit_detail, 'parent_ids', []) # Only include if it's a merge commit (has multiple parents) if len(parent_ids) > 1: commit_data = { "id": getattr(commit, "id", None), "short_id": getattr(commit, "short_id", None), "title": getattr(commit, "title", None), "message": getattr(commit, "message", None), "author_name": getattr(commit, "author_name", None), "author_email": getattr(commit, "author_email", None), "authored_date": getattr(commit, "authored_date", None), "committer_name": getattr(commit, "committer_name", None), "committer_email": getattr(commit, "committer_email", None), "committed_date": getattr(commit, "committed_date", None), "created_at": getattr(commit, "created_at", None), "web_url": getattr(commit, "web_url", None), "parent_ids": parent_ids, "parent_count": len(parent_ids), "project_id": getattr(project, "id", None), "project_name": getattr(project, "name", None) } all_merge_commits.append(commit_data) except Exception: # Skip commits we can't access continue except Exception: # Skip projects we can't access continue user_info = self._get_user_info(username) return { "user": user_info, "merge_commits": all_merge_commits, "total_count": len(all_merge_commits), "filters": { "username": username, "project_id": project_id, "since": since, "until": until, } } @retry_on_error() def get_user_code_changes_summary(self, username: str, project_id: Optional[str] = None, since: Optional[str] = None, until: Optional[str] = None, per_page: int = DEFAULT_PAGE_SIZE) -> Dict[str, Any]: """Get a summary of code changes made by a user""" if project_id: projects = [self._get_project(project_id)] else: # Get all accessible projects (limited search) projects = self.gl.projects.list(membership=True, get_all=False, per_page=20) all_commits = [] total_additions = 0 total_deletions = 0 for project in projects: try: # Get commits by user kwargs = { "author": username, "get_all": False, "per_page": min(per_page, MAX_PAGE_SIZE), "with_stats": True, } if since: kwargs["since"] = since if until: kwargs["until"] = until response = project.commits.list(**kwargs) # Process commits from this project for commit in response: try: commit_detail = project.commits.get(getattr(commit, "id", None)) stats = getattr(commit_detail, 'stats', {}) additions = stats.get('additions', 0) deletions = stats.get('deletions', 0) total_additions += additions total_deletions += deletions commit_data = { "id": getattr(commit, "id", None), "short_id": getattr(commit, "short_id", None), "title": getattr(commit, "title", None), "authored_date": getattr(commit, "authored_date", None), "additions": additions, "deletions": deletions, "total_changes": additions + deletions, "project_id": getattr(project, "id", None), "project_name": getattr(project, "name", None) } all_commits.append(commit_data) except Exception: # Skip commits we can't get details for continue except Exception: # Skip projects we can't access continue user_info = self._get_user_info(username) total_commits = len(all_commits) return { "user": user_info, "summary": { "total_commits": total_commits, "total_additions": total_additions, "total_deletions": total_deletions, "total_changes": total_additions + total_deletions, "average_changes_per_commit": round((total_additions + total_deletions) / max(total_commits, 1), 2) }, "commits": all_commits, "filters": { "project_id": project_id, "since": since, "until": until, } } @retry_on_error() def get_user_snippets(self, username: str, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1) -> Dict[str, Any]: """Get snippets created by a user""" # First get the user to get their ID users = self.gl.users.list(username=username, get_all=False, per_page=1) if not users: return {"snippets": [], "total_count": 0, "error": "User not found"} user_id = users[0].id user_info = self._get_user_info(username) # Get user's snippets kwargs = { "author_id": user_id, "get_all": False, "per_page": min(per_page, MAX_PAGE_SIZE), "page": page, } response = self.gl.snippets.list(**kwargs) snippets = [] for snippet in response: snippet_data = { "id": snippet.id, "title": snippet.title, "file_name": snippet.file_name, "description": getattr(snippet, 'description', ''), "visibility": snippet.visibility, "author": { "id": snippet.author.get("id"), "username": snippet.author.get("username"), "name": snippet.author.get("name"), } if hasattr(snippet, 'author') and snippet.author else user_info, "created_at": snippet.created_at, "updated_at": snippet.updated_at, "expires_at": getattr(snippet, 'expires_at', None), "web_url": snippet.web_url, } # Try to get content preview try: snippet_detail = self.gl.snippets.get(snippet.id) if hasattr(snippet_detail, 'content'): content = snippet_detail.content or "" snippet_data["content_preview"] = content[:500] + ("..." if len(content) > 500 else "") snippet_data["content_size"] = len(content) else: snippet_data["content_preview"] = "[Content unavailable]" snippet_data["content_size"] = 0 except Exception: snippet_data["content_preview"] = "[Content access denied]" snippet_data["content_size"] = 0 snippets.append(snippet_data) return { "user": user_info, "snippets": snippets, "total_count": len(snippets), "filters": { "username": username, "author_id": user_id, } } # User's Comments & Discussions methods @retry_on_error() def get_user_issue_comments(self, username: str, project_id: Optional[str] = None, since: Optional[str] = None, until: Optional[str] = None, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1) -> Dict[str, Any]: """Get issue comments made by a user""" user_info = self._get_user_info(username) if project_id: project = self._get_project(project_id) projects = [project] else: # Get all accessible projects (limited search) projects = self.gl.projects.list(membership=True, get_all=False, per_page=20) all_comments = [] for project in projects: try: # Get issues with notes issues = project.issues.list(get_all=False, per_page=50) for issue in issues: try: notes = issue.notes.list(get_all=False, per_page=50) for note in notes: # Check if the note author matches our user note_author = getattr(note, 'author', {}) if isinstance(note_author, dict): note_username = note_author.get('username', '') else: note_username = getattr(note_author, 'username', '') if note_username == username: # Filter by date if specified note_date = getattr(note, 'created_at', None) if since and note_date and note_date < since: continue if until and note_date and note_date > until: continue note_body = getattr(note, 'body', '') comment_data = { "id": getattr(note, 'id', None), "body": note_body[:500] + ("..." if len(note_body) > 500 else ""), "created_at": getattr(note, 'created_at', None), "updated_at": getattr(note, 'updated_at', None), "system": getattr(note, 'system', False), "noteable_type": "Issue", "noteable_id": getattr(issue, 'iid', None), "issue": { "id": getattr(issue, 'id', None), "iid": getattr(issue, 'iid', None), "title": getattr(issue, 'title', None), "web_url": getattr(issue, 'web_url', None) }, "project": { "id": getattr(project, 'id', None), "name": getattr(project, 'name', None), "path_with_namespace": getattr(project, 'path_with_namespace', None) } } all_comments.append(comment_data) except Exception: continue except Exception: continue # Sort by creation date and paginate all_comments.sort(key=lambda x: x["created_at"], reverse=True) start_idx = (page - 1) * per_page end_idx = start_idx + per_page paginated_comments = all_comments[start_idx:end_idx] return { "user": user_info, "comments": paginated_comments, "total_count": len(all_comments), "filters": { "project_id": project_id, "since": since, "until": until, } } @retry_on_error() def get_user_mr_comments(self, username: str, project_id: Optional[str] = None, since: Optional[str] = None, until: Optional[str] = None, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1) -> Dict[str, Any]: """Get MR comments made by a user""" user_info = self._get_user_info(username) if project_id: project = self._get_project(project_id) projects = [project] else: # Get all accessible projects (limited search) projects = self.gl.projects.list(membership=True, get_all=False, per_page=20) all_comments = [] for project in projects: try: # Get merge requests with notes mrs = project.mergerequests.list(get_all=False, per_page=50) for mr in mrs: try: notes = mr.notes.list(get_all=False, per_page=50) for note in notes: # Check if the note author matches our user note_author = getattr(note, 'author', {}) if isinstance(note_author, dict): note_username = note_author.get('username', '') else: note_username = getattr(note_author, 'username', '') if note_username == username: # Filter by date if specified note_date = getattr(note, 'created_at', None) if since and note_date and note_date < since: continue if until and note_date and note_date > until: continue note_body = getattr(note, 'body', '') comment_data = { "id": getattr(note, 'id', None), "body": note_body[:500] + ("..." if len(note_body) > 500 else ""), "created_at": getattr(note, 'created_at', None), "updated_at": getattr(note, 'updated_at', None), "system": getattr(note, 'system', False), "noteable_type": "MergeRequest", "noteable_id": getattr(mr, 'iid', None), "merge_request": { "id": getattr(mr, 'id', None), "iid": getattr(mr, 'iid', None), "title": getattr(mr, 'title', None), "web_url": getattr(mr, 'web_url', None), "state": getattr(mr, 'state', None) }, "project": { "id": getattr(project, 'id', None), "name": getattr(project, 'name', None), "path_with_namespace": getattr(project, 'path_with_namespace', None) } } all_comments.append(comment_data) except Exception: continue except Exception: continue # Sort by creation date and paginate all_comments.sort(key=lambda x: x["created_at"], reverse=True) start_idx = (page - 1) * per_page end_idx = start_idx + per_page paginated_comments = all_comments[start_idx:end_idx] return { "user": user_info, "comments": paginated_comments, "total_count": len(all_comments), "filters": { "project_id": project_id, "since": since, "until": until, } } @retry_on_error() def get_user_discussion_threads(self, username: str, project_id: Optional[str] = None, thread_status: Optional[str] = None, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1) -> Dict[str, Any]: """Get discussion threads started by a user""" user_info = self._get_user_info(username) if project_id: project = self._get_project(project_id) projects = [project] else: # Get all accessible projects (limited search) projects = self.gl.projects.list(membership=True, get_all=False, per_page=20) all_threads = [] for project in projects: try: # Get merge requests to check for discussions mrs = project.mergerequests.list(get_all=False, per_page=50) for mr in mrs: try: discussions = mr.discussions.list(get_all=False, per_page=50) for discussion in discussions: # Check if discussion was started by our user notes = getattr(discussion, 'notes', []) if not notes: continue first_note = notes[0] if isinstance(notes, list) else notes note_author = getattr(first_note, 'author', {}) if isinstance(note_author, dict): note_username = note_author.get('username', '') else: note_username = getattr(note_author, 'username', '') if note_username == username: # Filter by thread status if specified discussion_resolved = getattr(discussion, 'resolved', False) if thread_status == "resolved" and not discussion_resolved: continue if thread_status == "unresolved" and discussion_resolved: continue thread_data = { "id": getattr(discussion, 'id', None), "resolved": discussion_resolved, "notes_count": len(notes) if isinstance(notes, list) else 1, "created_at": getattr(first_note, 'created_at', None), "first_note": { "body": getattr(first_note, 'body', '')[:300] + ("..." if len(getattr(first_note, 'body', '')) > 300 else ""), }, "noteable_type": "MergeRequest", "noteable_id": getattr(mr, 'iid', None), "merge_request": { "id": getattr(mr, 'id', None), "iid": getattr(mr, 'iid', None), "title": getattr(mr, 'title', None), "web_url": getattr(mr, 'web_url', None), "state": getattr(mr, 'state', None) }, "project": { "id": getattr(project, 'id', None), "name": getattr(project, 'name', None), "path_with_namespace": getattr(project, 'path_with_namespace', None) } } all_threads.append(thread_data) except Exception: continue except Exception: continue # Sort by creation date and paginate all_threads.sort(key=lambda x: x.get("created_at", ""), reverse=True) start_idx = (page - 1) * per_page end_idx = start_idx + per_page paginated_threads = all_threads[start_idx:end_idx] return { "user": user_info, "discussion_threads": paginated_threads, "total_count": len(all_threads), "filters": { "project_id": project_id, "thread_status": thread_status, } } @retry_on_error() def get_user_resolved_threads(self, username: str, project_id: Optional[str] = None, since: Optional[str] = None, until: Optional[str] = None, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1) -> Dict[str, Any]: """Get discussion threads resolved by a user""" user_info = self._get_user_info(username) if project_id: project = self._get_project(project_id) projects = [project] else: # Get all accessible projects (limited search) projects = self.gl.projects.list(membership=True, get_all=False, per_page=20) all_resolved_threads = [] for project in projects: try: # Get merge requests to check for resolved discussions mrs = project.mergerequests.list(get_all=False, per_page=50) for mr in mrs: try: discussions = mr.discussions.list(get_all=False, per_page=50) for discussion in discussions: # Check if discussion is resolved if not getattr(discussion, 'resolved', False): continue # Get notes to find who resolved it notes = getattr(discussion, 'notes', []) if not notes: continue # Look for resolution by checking notes for note in notes if isinstance(notes, list) else [notes]: # Check for system notes about resolution if (hasattr(note, 'system') and note.system and hasattr(note, 'body') and 'resolved' in note.body.lower()): note_author = getattr(note, 'author', {}) if isinstance(note_author, dict): note_username = note_author.get('username', '') else: note_username = getattr(note_author, 'username', '') if note_username == username: # Filter by date if specified if since and hasattr(note, 'created_at') and note.created_at < since: continue if until and hasattr(note, 'created_at') and note.created_at > until: continue thread_data = { "id": getattr(discussion, 'id', None), "resolved_at": getattr(note, 'created_at', None), "resolved_by": note_username, "notes_count": len(notes) if isinstance(notes, list) else 1, "noteable_type": "MergeRequest", "noteable_id": getattr(mr, 'iid', None), "merge_request": { "id": getattr(mr, 'id', None), "iid": getattr(mr, 'iid', None), "title": getattr(mr, 'title', None), "web_url": getattr(mr, 'web_url', None), "state": getattr(mr, 'state', None) }, "project": { "id": getattr(project, 'id', None), "name": getattr(project, 'name', None), "path_with_namespace": getattr(project, 'path_with_namespace', None) } } all_resolved_threads.append(thread_data) break # Only count each discussion once except Exception: continue except Exception: continue # Sort by resolution date and paginate all_resolved_threads.sort(key=lambda x: x.get("resolved_at", ""), reverse=True) start_idx = (page - 1) * per_page end_idx = start_idx + per_page paginated_threads = all_resolved_threads[start_idx:end_idx] return { "user": user_info, "resolved_threads": paginated_threads, "total_count": len(all_resolved_threads), "filters": { "project_id": project_id, "since": since, "until": until, } } @retry_on_error() def get_repository_tree(self, project_id: str, path: str = "", ref: Optional[str] = None, recursive: bool = False) -> Dict[str, Any]: """Get repository tree (list of files and directories). Args: project_id: The ID or path of the project path: The path inside repository to list ref: The name of a repository branch or tag recursive: Boolean value to get a recursive tree Returns: Dict containing repository tree items """ try: project = self.gl.projects.get(project_id) kwargs = {"path": path, "recursive": recursive} if ref: kwargs["ref"] = ref tree = project.repository_tree(**kwargs) return { "tree": [ { "id": item.get("id"), "name": item.get("name"), "type": item.get("type"), # "tree" for directory, "blob" for file "path": item.get("path"), "mode": item.get("mode"), } for item in tree ], "project_id": project_id, "path": path, "ref": ref, } except gitlab.exceptions.GitlabGetError as e: return {"error": f"Failed to get repository tree: {str(e)}"} @retry_on_error() def search_in_project(self, project_id: str, scope: str, search: str, per_page: int = DEFAULT_PAGE_SIZE, page: int = 1) -> Dict[str, Any]: """Search within a project. Args: project_id: The ID or path of the project scope: Scope of search (blobs, issues, merge_requests, milestones, notes, wiki_blobs, commits) search: Search query per_page: Number of items per page page: Page number Returns: Dict containing search results """ try: project = self.gl.projects.get(project_id) results = project.search(scope, search, get_all=False, per_page=per_page, page=page) # Format results based on scope formatted_results = [] for item in results: if scope == "blobs": formatted_results.append({ "basename": getattr(item, "basename", None), "data": getattr(item, "data", None), "path": getattr(item, "path", None), "filename": getattr(item, "filename", None), "id": getattr(item, "id", None), "ref": getattr(item, "ref", None), "startline": getattr(item, "startline", None), "project_id": getattr(item, "project_id", None), }) elif scope == "commits": formatted_results.append({ "id": getattr(item, "id", None), "short_id": getattr(item, "short_id", None), "title": getattr(item, "title", None), "message": getattr(item, "message", None), "author_name": getattr(item, "author_name", None), "created_at": getattr(item, "created_at", None), }) elif scope in ["issues", "merge_requests"]: formatted_results.append({ "id": getattr(item, "id", None), "iid": getattr(item, "iid", None), "title": getattr(item, "title", None), "description": getattr(item, "description", None), "state": getattr(item, "state", None), "created_at": getattr(item, "created_at", None), "updated_at": getattr(item, "updated_at", None), "web_url": getattr(item, "web_url", None), }) else: # Generic formatting for other scopes formatted_results.append({ k: getattr(item, k, None) for k in dir(item) if not k.startswith('_') }) pagination = { "page": page, "per_page": per_page, "total": getattr(results, "total", None), "total_pages": getattr(results, "total_pages", None), "next_page": getattr(results, "next_page", None), "prev_page": getattr(results, "prev_page", None), } return { "results": formatted_results, "pagination": pagination, "scope": scope, "search": search, "project_id": project_id, } except gitlab.exceptions.GitlabGetError as e: return {"error": f"Search failed: {str(e)}"} except Exception as e: return {"error": f"Failed to search in project: {str(e)}"} @retry_on_error() def summarize_merge_request(self, project_id: str, mr_iid: int, max_length: int = 500) -> Dict[str, Any]: """Generate an AI-friendly summary of a merge request. This method retrieves MR details, changes, and discussions, then formats them into a concise summary suitable for LLM context. Args: project_id: The ID or path of the project mr_iid: The IID of the merge request max_length: Maximum length for description/discussion summary Returns: Dict containing structured MR summary with key information """ try: project = self.gl.projects.get(project_id) mr = project.mergerequests.get(mr_iid) # Get MR changes changes = mr.changes() # Get discussions discussions = mr.discussions.list(get_all=True) # Summarize files changed files_changed = [] for change in changes.get("changes", []): files_changed.append({ "path": change.get("new_path"), "additions": change.get("diff", "").count("\n+"), "deletions": change.get("diff", "").count("\n-"), }) # Summarize discussions discussion_summary = [] for discussion in discussions[:5]: # Limit to first 5 discussions notes = discussion.attributes.get("notes", []) if notes: first_note = notes[0] discussion_summary.append({ "author": first_note.get("author", {}).get("username"), "created_at": first_note.get("created_at"), "resolved": discussion.attributes.get("resolved", False), "note_count": len(notes), }) # Create summary description = mr.description or "" if len(description) > max_length: description = description[:max_length] + "..." return { "mr_iid": mr_iid, "title": mr.title, "state": mr.state, "author": mr.author.get("username"), "created_at": mr.created_at, "updated_at": mr.updated_at, "source_branch": mr.source_branch, "target_branch": mr.target_branch, "description_summary": description, "files_changed_count": len(files_changed), "files_changed_sample": files_changed[:10], # First 10 files "additions_total": sum(f["additions"] for f in files_changed), "deletions_total": sum(f["deletions"] for f in files_changed), "discussion_count": len(discussions), "discussions_summary": discussion_summary, "merge_status": mr.merge_status, "has_conflicts": mr.has_conflicts, "labels": mr.labels, "web_url": mr.web_url, } except gitlab.exceptions.GitlabGetError as e: return {"error": f"Failed to get merge request: {str(e)}"} @retry_on_error() def batch_operations(self, project_id: str, operations: List[Dict[str, Any]], stop_on_error: bool = True) -> Dict[str, Any]: """Execute multiple operations in batch. Args: project_id: The ID or path of the project operations: List of operations to execute stop_on_error: Whether to stop on first error Returns: Dict containing results of all operations """ results = [] for i, operation in enumerate(operations): try: # Validate operation structure if not isinstance(operation, dict): result = {"error": f"Operation at index {i} must be a dictionary, got {type(operation).__name__}"} elif "type" not in operation: result = {"error": f"Operation at index {i} missing required 'type' field"} else: op_type = operation.get("type") op_params = operation.get("params", {}) # Add project_id to params if not present if "project_id" not in op_params: op_params["project_id"] = project_id # Execute operation based on type if op_type == "get_issue": result = self.get_issue(**op_params) elif op_type == "get_merge_request": result = self.get_merge_request(**op_params) elif op_type == "list_issues": result = self.list_issues(**op_params) elif op_type == "list_merge_requests": result = self.list_merge_requests(**op_params) elif op_type == "get_file_content": result = self.get_file_content(**op_params) elif op_type == "get_commits": result = self.get_commits(**op_params) else: result = {"error": f"Unknown operation type: {op_type}"} results.append({ "index": i, "operation": op_type, "success": "error" not in result, "result": result, }) if stop_on_error and "error" in result: break except Exception as e: error_result = { "index": i, "operation": operation.get("type"), "success": False, "result": {"error": str(e)}, } results.append(error_result) if stop_on_error: break return { "operations_count": len(operations), "executed_count": len(results), "success_count": sum(1 for r in results if r["success"]), "results": results, } __all__ = ["GitLabClient", "GitLabConfig"]

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Vijay-Duke/mcp-gitlab'

If you have feedback or need assistance with the MCP directory API, please join our Discord server