snodo-provider-gitlab 0.18.0__tar.gz

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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: snodo-provider-gitlab
3
+ Version: 0.18.0
4
+ Summary: GitLab code-host provider plugin for Snodo
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: snodo-tools==0.19.2
7
+ Requires-Dist: python-gitlab>=4.0.0
@@ -0,0 +1,17 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "snodo-provider-gitlab"
7
+ version = "0.18.0"
8
+ description = "GitLab code-host provider plugin for Snodo"
9
+ requires-python = ">=3.12"
10
+ dependencies = ["snodo-tools==0.19.2", "python-gitlab>=4.0.0"]
11
+
12
+ [project.entry-points."snodo.providers"]
13
+ gitlab = "snodo_provider_gitlab:GitLabProvider"
14
+
15
+ [tool.setuptools.packages.find]
16
+ where = ["src"]
17
+ namespaces = false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: snodo-provider-gitlab
3
+ Version: 0.18.0
4
+ Summary: GitLab code-host provider plugin for Snodo
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: snodo-tools==0.19.2
7
+ Requires-Dist: python-gitlab>=4.0.0
@@ -0,0 +1,8 @@
1
+ pyproject.toml
2
+ src/snodo_provider_gitlab/__init__.py
3
+ src/snodo_provider_gitlab.egg-info/PKG-INFO
4
+ src/snodo_provider_gitlab.egg-info/SOURCES.txt
5
+ src/snodo_provider_gitlab.egg-info/dependency_links.txt
6
+ src/snodo_provider_gitlab.egg-info/entry_points.txt
7
+ src/snodo_provider_gitlab.egg-info/requires.txt
8
+ src/snodo_provider_gitlab.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [snodo.providers]
2
+ gitlab = snodo_provider_gitlab:GitLabProvider
@@ -0,0 +1,2 @@
1
+ snodo-tools==0.19.2
2
+ python-gitlab>=4.0.0