snodo-provider-gitlab 0.18.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- snodo_provider_gitlab/__init__.py +170 -0
- snodo_provider_gitlab-0.18.0.dist-info/METADATA +7 -0
- snodo_provider_gitlab-0.18.0.dist-info/RECORD +6 -0
- snodo_provider_gitlab-0.18.0.dist-info/WHEEL +5 -0
- snodo_provider_gitlab-0.18.0.dist-info/entry_points.txt +2 -0
- snodo_provider_gitlab-0.18.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""GitLab merge-request provider plugin."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from snodo.providers.base import CodeHostProvider, ProviderError
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import gitlab
|
|
17
|
+
except ImportError: # pragma: no cover - dependency is declared by this plugin
|
|
18
|
+
gitlab = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GitLabProvider(CodeHostProvider):
|
|
22
|
+
"""Code-host provider backed by GitLab merge requests."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, project_root: str = ".", metadata: Optional[dict] = None,
|
|
25
|
+
repo_path: Optional[str] = None, token: Optional[str] = None):
|
|
26
|
+
metadata = metadata or {}
|
|
27
|
+
self._url = _normalize_url(metadata.get("gitlab_url") or metadata.get("gitlab_host") or "https://gitlab.com")
|
|
28
|
+
repo_path = repo_path or metadata.get("gitlab_repo")
|
|
29
|
+
if not repo_path:
|
|
30
|
+
remote = _get_git_remote(project_root)
|
|
31
|
+
repo_path = parse_gitlab_path(remote, self._url) if remote else None
|
|
32
|
+
if not repo_path:
|
|
33
|
+
raise ProviderError("Could not determine GitLab project. Set metadata.gitlab_repo or add a GitLab git remote.")
|
|
34
|
+
if gitlab is None:
|
|
35
|
+
raise ProviderError("python-gitlab is required. Install snodo-provider-gitlab.")
|
|
36
|
+
self._token = token or metadata.get("gitlab_token") or os.environ.get("GITLAB_TOKEN")
|
|
37
|
+
if not self._token:
|
|
38
|
+
raise ProviderError("GitLab token required. Set metadata.gitlab_token or the GITLAB_TOKEN environment variable.")
|
|
39
|
+
try:
|
|
40
|
+
self._client = gitlab.Gitlab(self._url, private_token=self._token)
|
|
41
|
+
self._project = self._client.projects.get(repo_path)
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
raise ProviderError(f"Failed to connect to GitLab project '{repo_path}': {exc}") from exc
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def claims_remote(cls, url: str) -> bool:
|
|
47
|
+
host = _remote_host(url)
|
|
48
|
+
return host == "gitlab.com" or host in _configured_hosts()
|
|
49
|
+
|
|
50
|
+
def create_change_request(self, branch: str, title: str, body: str,
|
|
51
|
+
target_branch: Optional[str] = None) -> str:
|
|
52
|
+
try:
|
|
53
|
+
mr = self._project.mergerequests.create({
|
|
54
|
+
"source_branch": branch, "target_branch": target_branch or self._project.default_branch,
|
|
55
|
+
"title": title, "description": body,
|
|
56
|
+
})
|
|
57
|
+
reference = str(mr.iid)
|
|
58
|
+
try:
|
|
59
|
+
try:
|
|
60
|
+
self._project.labels.get("snodo")
|
|
61
|
+
except Exception:
|
|
62
|
+
self._project.labels.create({"name": "snodo", "color": "#6f42c1"})
|
|
63
|
+
labels = list(getattr(mr, "labels", []) or [])
|
|
64
|
+
if "snodo" not in labels:
|
|
65
|
+
labels.append("snodo")
|
|
66
|
+
mr.labels = labels
|
|
67
|
+
mr.save()
|
|
68
|
+
except Exception:
|
|
69
|
+
logger.warning("Could not apply the snodo label to merge request %s", reference,
|
|
70
|
+
exc_info=True)
|
|
71
|
+
return reference
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
raise ProviderError(f"Failed to create merge request: {exc}") from exc
|
|
74
|
+
|
|
75
|
+
def read_change_request_diff(self, change_request_id: str) -> str:
|
|
76
|
+
try:
|
|
77
|
+
changes = self._mr(change_request_id).changes().get("changes", [])
|
|
78
|
+
return "\n".join(
|
|
79
|
+
f"diff --git a/{item['old_path']} b/{item['new_path']}\n{item.get('diff') or '(binary file)'}"
|
|
80
|
+
for item in changes
|
|
81
|
+
) or "(no changes)"
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
raise ProviderError(f"Failed to read merge request diff: {exc}") from exc
|
|
84
|
+
|
|
85
|
+
def post_change_request_comment(self, change_request_id: str, comment: str) -> str:
|
|
86
|
+
try:
|
|
87
|
+
note = self._mr(change_request_id).discussions.create({"body": comment})
|
|
88
|
+
return str(getattr(note, "id", ""))
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
raise ProviderError(f"Failed to post merge request comment: {exc}") from exc
|
|
91
|
+
|
|
92
|
+
def approve_change_request(self, change_request_id: str) -> str:
|
|
93
|
+
try:
|
|
94
|
+
self._mr(change_request_id).approve()
|
|
95
|
+
return f"Change request {change_request_id} approved"
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
raise ProviderError(f"Failed to approve merge request: {exc}") from exc
|
|
98
|
+
|
|
99
|
+
def request_change_request_changes(self, change_request_id: str, reason: str) -> str:
|
|
100
|
+
try:
|
|
101
|
+
mr = self._mr(change_request_id)
|
|
102
|
+
mr.discussions.create({"body": reason})
|
|
103
|
+
mr.unapprove()
|
|
104
|
+
return f"Change request {change_request_id}: changes requested"
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
raise ProviderError(f"Failed to request merge request changes: {exc}") from exc
|
|
107
|
+
|
|
108
|
+
def merge_change_request(self, change_request_id: str) -> str:
|
|
109
|
+
try:
|
|
110
|
+
result = self._mr(change_request_id).merge()
|
|
111
|
+
return f"Change request {change_request_id} merged: {str(result.get('sha', ''))[:8]}"
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
raise ProviderError(f"Failed to merge merge request: {exc}") from exc
|
|
114
|
+
|
|
115
|
+
def read_change_request_discussion(self, change_request_id: str) -> str:
|
|
116
|
+
try:
|
|
117
|
+
mr = self._mr(change_request_id)
|
|
118
|
+
comments = []
|
|
119
|
+
for discussion in mr.discussions.list(all=True):
|
|
120
|
+
for note in getattr(discussion, "attributes", {}).get("notes", []):
|
|
121
|
+
if note.get("system"):
|
|
122
|
+
continue
|
|
123
|
+
comments.append({"author": (note.get("author") or {}).get("username", ""),
|
|
124
|
+
"body": note.get("body", "")})
|
|
125
|
+
return json.dumps({"title": mr.title, "comments": comments, "reviews": []})
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
raise ProviderError(f"Failed to read merge request discussion: {exc}") from exc
|
|
128
|
+
|
|
129
|
+
def _mr(self, change_request_id: str):
|
|
130
|
+
return self._project.mergerequests.get(int(change_request_id))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _normalize_url(url: str) -> str:
|
|
134
|
+
return url.rstrip("/")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _remote_host(url: str) -> str:
|
|
138
|
+
if "://" in url:
|
|
139
|
+
return (urlparse(url).hostname or "").lower().rstrip(".")
|
|
140
|
+
match = re.match(r"(?:[^@]+@)?([^:]+):", url)
|
|
141
|
+
return match.group(1).lower().rstrip(".") if match else ""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _configured_hosts() -> set[str]:
|
|
145
|
+
# claims_remote has no protocol instance; the conventional env declaration
|
|
146
|
+
# permits registry detection for self-hosted GitLab installations.
|
|
147
|
+
host = os.environ.get("GITLAB_HOST") or os.environ.get("GITLAB_URL", "")
|
|
148
|
+
return {urlparse(_normalize_url(host if "://" in host else f"https://{host}")).hostname or ""} if host else set()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_gitlab_path(remote: str, base_url: str = "https://gitlab.com") -> Optional[str]:
|
|
152
|
+
host = _remote_host(remote)
|
|
153
|
+
if host != (urlparse(base_url).hostname or "").lower():
|
|
154
|
+
return None
|
|
155
|
+
if "://" in remote:
|
|
156
|
+
path = urlparse(remote).path.lstrip("/")
|
|
157
|
+
else:
|
|
158
|
+
match = re.match(r"(?:[^@]+@)?[^:]+:(.+)$", remote)
|
|
159
|
+
path = match.group(1) if match else ""
|
|
160
|
+
return re.sub(r"\.git$", "", path).strip("/") or None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _get_git_remote(project_root: str) -> Optional[str]:
|
|
164
|
+
try:
|
|
165
|
+
result = subprocess.run(["git", "remote", "get-url", "origin"], # noqa: S603, S607 - fixed git argv, no shell; git resolved from PATH by design
|
|
166
|
+
cwd=project_root,
|
|
167
|
+
capture_output=True, text=True, check=True)
|
|
168
|
+
return result.stdout.strip()
|
|
169
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
170
|
+
return None
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
snodo_provider_gitlab/__init__.py,sha256=uyTTyZCj91Z9hB4O29PsXsTKLiDyL67E9Odt98Y0FDY,7607
|
|
2
|
+
snodo_provider_gitlab-0.18.0.dist-info/METADATA,sha256=JP5edQiksNQosHtmdXhg_rvObV3DGroEiVQrb5ZZpH8,213
|
|
3
|
+
snodo_provider_gitlab-0.18.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
4
|
+
snodo_provider_gitlab-0.18.0.dist-info/entry_points.txt,sha256=AE3AnFc4ztvlFriPA8HwZFysfaL-CZCfgx6TjH5MiPQ,64
|
|
5
|
+
snodo_provider_gitlab-0.18.0.dist-info/top_level.txt,sha256=3ig8_S4-Gla2XpwWt8AeTL2Z20wtwK9pUILljLv8J_o,22
|
|
6
|
+
snodo_provider_gitlab-0.18.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
snodo_provider_gitlab
|