D7net
Home
Console
Upload
information
Create File
Create Folder
About
Tools
blackhat.pw
:
/
kunden
/
usr
/
lib
/
python3
/
dist-packages
/
breezy
/
plugins
/
gitlab
/
Filename :
forge.py
back
Copy
# Copyright (C) 2018 Breezy Developers # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Support for GitLab.""" import json import os import re import time from datetime import datetime from typing import Optional from ... import bedding, controldir, errors, urlutils from ... import branch as _mod_branch from ...forge import ( Forge, ForgeLoginRequired, MergeProposal, MergeProposalBuilder, MergeProposalExists, NoSuchProject, PrerequisiteBranchUnsupported, SourceNotDerivedFromTarget, UnsupportedForge, determine_title, ) from ...git.urls import git_url_to_bzr_url from ...trace import mutter from ...transport import get_transport _DEFAULT_FILES = ["/etc/python-gitlab.cfg", "~/.python-gitlab.cfg"] DEFAULT_PAGE_SIZE = 50 DEFAULT_PREFERRED_SCHEMES = ["ssh", "http"] SCHEME_MAP = { "git+ssh": "ssh_url_to_repo", "ssh": "ssh_url_to_repo", "http": "http_url_to_repo", "https": "http_url_to_repo", } def mp_status_to_status(status): return {"all": "all", "open": "opened", "merged": "merged", "closed": "closed"}[ status ] def parse_timestring(ts): return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%S.%fZ") class NotGitLabUrl(errors.BzrError): _fmt = "Not a GitLab URL: %(url)s" def __init__(self, url): errors.BzrError.__init__(self) self.url = url class NotMergeRequestUrl(errors.BzrError): _fmt = "Not a merge proposal URL: %(url)s" def __init__(self, host, url): errors.BzrError.__init__(self) self.host = host self.url = url class GitLabError(errors.BzrError): _fmt = "GitLab error: %(error)s" def __init__(self, error, full_response): errors.BzrError.__init__(self) self.error = error self.full_response = full_response class GitLabUnprocessable(errors.BzrError): _fmt = "GitLab can not process request: %(error)s." def __init__(self, error, full_response): errors.BzrError.__init__(self) self.error = error self.full_response = full_response class DifferentGitLabInstances(errors.BzrError): _fmt = ( "Can't create merge proposals across GitLab instances: " "%(source_host)s and %(target_host)s" ) def __init__(self, source_host, target_host): self.source_host = source_host self.target_host = target_host class GitLabLoginMissing(ForgeLoginRequired): _fmt = "Please log into GitLab instance at %(forge)s" class GitlabLoginError(errors.BzrError): _fmt = "Error logging in: %(error)s" def __init__(self, error): self.error = error class GitLabConflict(errors.BzrError): _fmt = "Conflict during operation: %(reason)s" def __init__(self, reason): errors.BzrError(self) self.reason = reason class ForkingDisabled(errors.BzrError): _fmt = "Forking on project %(project)s is disabled." def __init__(self, project): self.project = project class MergeRequestConflict(Exception): """Raised when a merge requests conflicts.""" def __init__(self, reason): self.reason = reason class ProjectCreationTimeout(errors.BzrError): _fmt = "Timeout (%(timeout)ds) while waiting for project %(project)s to be created." def __init__(self, project, timeout): self.project = project self.timeout = timeout def store_gitlab_token(name, url, private_token): """Store a GitLab token in a configuration file.""" from breezy.config import AuthenticationConfig auth_config = AuthenticationConfig() auth_config._set_option(name, "url", url) auth_config._set_option(name, "forge", "gitlab") auth_config._set_option(name, "private_token", private_token) def iter_tokens(): import configparser config = configparser.ConfigParser() config.read( [os.path.expanduser(p) for p in _DEFAULT_FILES] + # backwards compatibility [os.path.join(bedding.config_dir(), "gitlab.conf")] ) for name, creds in config.items(): if "url" not in creds: continue yield name, creds from breezy.config import AuthenticationConfig auth_config = AuthenticationConfig() for name, creds in auth_config._get_config().iteritems(): if creds.get("forge") == "gitlab": yield name, creds else: url = creds.get("url") # Hack for those without forge set if url and url.startswith("https://gitlab.com/"): yield name, creds def get_credentials_by_url(url): for _name, credentials in iter_tokens(): if credentials["url"].rstrip("/") == url.rstrip("/"): return credentials else: return None def parse_gitlab_url(url): (scheme, user, password, host, port, path) = urlutils.parse_url(url) if scheme not in ("git+ssh", "https", "http"): raise NotGitLabUrl(url) if not host: raise NotGitLabUrl(url) path = path.strip("/") if path.endswith(".git"): path = path[:-4] return host, path def parse_gitlab_branch_url(branch): url = urlutils.strip_segment_parameters(branch.user_url) host, path = parse_gitlab_url(url) return host, path, branch.name def parse_gitlab_merge_request_url(url): (scheme, user, password, host, port, path) = urlutils.parse_url(url) if scheme not in ("git+ssh", "https", "http"): raise NotGitLabUrl(url) if not host: raise NotGitLabUrl(url) path = path.strip("/") parts = path.split("/") if len(parts) < 2: raise NotMergeRequestUrl(host, url) if parts[-2] != "merge_requests": raise NotMergeRequestUrl(host, url) if parts[-3] == "-": project_name = "/".join(parts[:-3]) else: project_name = "/".join(parts[:-2]) return host, project_name, int(parts[-1]) def _unexpected_status(path, response): raise errors.UnexpectedHttpStatus( path, response.status, response.data.decode("utf-8", "replace"), headers=response.getheaders(), ) class GitLabMergeProposal(MergeProposal): supports_auto_merge = True def __init__(self, gl, mr): self.gl = gl self._mr = mr def _update(self, **kwargs): try: self.gl._update_merge_request( self._mr["project_id"], self._mr["iid"], kwargs ) except GitLabConflict as e: self.gl._handle_merge_request_conflict( e.reason, self.get_source_branch_url(), self._mr["target_project_id"] ) def __repr__(self): return "<{} at {!r}>".format(type(self).__name__, self._mr["web_url"]) @property def url(self): return self._mr["web_url"] def get_web_url(self): return self._mr["web_url"] def get_description(self): return self._mr["description"] def set_description(self, description): try: self._update(description=description) except errors.UnexpectedHttpStatus as e: # HACK: Some versions of GitLab apply the changes but fail with a 500 # This appears to happen at least with version 15.5.6 if e.code != 500: raise self._mr = self.gl._get_merge_request( self._mr["project_id"], self._mr["iid"] ) if self._mr["description"] != description: raise def get_commit_message(self): return self._mr.get("merge_commit_message") def set_commit_message(self, message): raise errors.UnsupportedOperation(self.set_commit_message, self) def get_title(self): return self._mr.get("title") def set_title(self, title): self._update(title=title) def _branch_url_from_project( self, project_id, branch_name, *, preferred_schemes=None ): if project_id is None: return None project = self.gl._get_project(project_id) if preferred_schemes is None: preferred_schemes = DEFAULT_PREFERRED_SCHEMES for scheme in preferred_schemes: if scheme in SCHEME_MAP: return gitlab_url_to_bzr_url(project[SCHEME_MAP[scheme]], branch_name) raise KeyError def get_source_branch_url(self, *, preferred_schemes=None): return self._branch_url_from_project( self._mr["source_project_id"], self._mr["source_branch"], preferred_schemes=preferred_schemes, ) def get_source_revision(self): from breezy.git.mapping import default_mapping sha = self._mr["sha"] if sha is None: return None return default_mapping.revision_id_foreign_to_bzr(sha.encode("ascii")) def get_target_branch_url(self, *, preferred_schemes=None): return self._branch_url_from_project( self._mr["target_project_id"], self._mr["target_branch"], preferred_schemes=preferred_schemes, ) def set_target_branch_name(self, name): self._update(target_branch=name) def _get_project_name(self, project_id): source_project = self.gl._get_project(project_id) return source_project["path_with_namespace"] def get_source_project(self): return self._get_project_name(self._mr["source_project_id"]) def get_target_project(self): return self._get_project_name(self._mr["target_project_id"]) def is_merged(self): return self._mr["state"] == "merged" def is_closed(self): return self._mr["state"] == "closed" def reopen(self): return self._update(state_event="reopen") def close(self): self._update(state_event="close") def merge(self, commit_message=None, auto=False): # https://docs.gitlab.com/ee/api/merge_requests.html#accept-mr ret = self.gl._merge_mr( self._mr["project_id"], self._mr["iid"], kwargs={ "merge_commit_message": commit_message, "merge_when_pipeline_succeeds": auto, }, ) self._mr.update(ret) def can_be_merged(self): if self._mr["merge_status"] == "cannot_be_merged": return False elif self._mr["merge_status"] == "can_be_merged": return True elif self._mr["merge_status"] in ( "unchecked", "cannot_be_merged_recheck", "checking", ): # See https://gitlab.com/gitlab-org/gitlab/-/commit/7517105303c for # an explanation of the distinction between unchecked and # cannot_be_merged_recheck return None else: raise ValueError(self._mr["merge_status"]) def get_merged_by(self): user = self._mr.get("merge_user") if user is None: return None return user["username"] def get_merged_at(self): merged_at = self._mr.get("merged_at") if merged_at is None: return None return parse_timestring(merged_at) def post_comment(self, body): kwargs = {"body": body} self.gl._post_merge_request_note( self._mr["project_id"], self._mr["iid"], kwargs ) def gitlab_url_to_bzr_url(url, name): return git_url_to_bzr_url(url, branch=name) class GitLab(Forge): """GitLab forge implementation.""" supports_merge_proposal_labels = True supports_merge_proposal_title = True supports_merge_proposal_commit_message = False supports_allow_collaboration = True merge_proposal_description_format = "markdown" def __repr__(self): return "<GitLab({!r})>".format(self.base_url) @property def base_url(self): return self.transport.base @property def base_hostname(self): return urlutils.parse_url(self.base_url)[3] def _find_correct_project_name(self, path): try: resp = self.transport.request( "GET", urlutils.join(self.base_url, path), headers=self.headers ) except errors.RedirectRequested as e: return urlutils.parse_url(e.target)[-1].strip("/") if resp.status != 200: _unexpected_status(path, resp) return None def _api_request(self, method, path, fields=None, body=None): return self.transport.request( method, urlutils.join(self.base_url, "api", "v4", path), headers=self.headers, fields=fields, body=body, ) def __init__(self, transport, private_token): self.transport = transport self.headers = {"Private-Token": private_token} self._current_user = None def _get_user(self, username): path = "users/{}".format(urlutils.quote(str(username), "")) response = self._api_request("GET", path) if response.status == 404: raise KeyError("no such user {}".format(username)) if response.status == 200: return json.loads(response.data) _unexpected_status(path, response) def _get_user_by_email(self, email): path = "users?search={}".format(urlutils.quote(str(email), "")) response = self._api_request("GET", path) if response.status == 404: raise KeyError("no such user {}".format(email)) if response.status == 200: ret = json.loads(response.data) if len(ret) != 1: raise ValueError("unexpected number of results; {!r}".format(ret)) return ret[0] _unexpected_status(path, response) def _get_project(self, project_name, _redirect_checked=False): path = "projects/{}".format(urlutils.quote(str(project_name), "")) response = self._api_request("GET", path) if response.status == 404: if not _redirect_checked: project_name = self._find_correct_project_name(project_name) if project_name is not None: return self._get_project(project_name, _redirect_checked=True) raise NoSuchProject(project_name) if response.status == 200: return json.loads(response.data) _unexpected_status(path, response) def _get_namespace(self, namespace): path = "namespaces/" + urlutils.quote(str(namespace), "") response = self._api_request("GET", path) if response.status == 200: return json.loads(response.data) if response.status == 404: return None _unexpected_status(path, response) def create_project(self, project_name, summary=None): if project_name.endswith(".git"): project_name = project_name[:-4] if "/" in project_name: namespace_path, path = project_name.lstrip("/").rsplit("/", 1) else: namespace_path = "" path = project_name namespace = self._get_namespace(namespace_path) if namespace is None: raise Exception("namespace {} does not exist".format(namespace_path)) fields = { "path": path, "namespace_id": namespace["id"], } if summary is not None: fields["description"] = summary response = self._api_request("POST", "projects", fields=fields) if response.status == 400: ret = json.loads(response.data) if ret.get("message", {}).get("path") == ["has already been taken"]: raise errors.AlreadyControlDirError(project_name) raise if response.status == 403: raise errors.PermissionDenied(response.text) if response.status not in (200, 201): _unexpected_status("projects", response) project = json.loads(response.data) return project def fork_project(self, project_name, timeout=50, interval=5, owner=None): path = "projects/{}/fork".format(urlutils.quote(str(project_name), "")) fields = {} if owner is not None: fields["namespace"] = owner response = self._api_request("POST", path, fields=fields) if response.status == 404: raise ForkingDisabled(project_name) if response.status == 409: resp = json.loads(response.data) raise GitLabConflict(resp.get("message")) if response.status not in (200, 201): _unexpected_status(path, response) # The response should be valid JSON, but let's ignore it project = json.loads(response.data) # Spin and wait until import_status for new project # is complete. deadline = time.time() + timeout while project["import_status"] not in ("finished", "none"): mutter("import status is %s", project["import_status"]) if time.time() > deadline: raise ProjectCreationTimeout(project["path_with_namespace"], timeout) time.sleep(interval) project = self._get_project(project["path_with_namespace"]) return project def _handle_merge_request_conflict(self, message, source_url, target_project): m = re.fullmatch( r"Another open merge request already exists for " r"this source branch: \!([0-9]+)", message[0], ) if m: merge_id = int(m.group(1)) mr = self._get_merge_request(target_project, merge_id) raise MergeProposalExists(source_url, GitLabMergeProposal(self, mr)) raise MergeRequestConflict(message) def get_current_user(self): if not self._current_user: self._retrieve_user() return self._current_user["username"] def get_user_url(self, username): return urlutils.join(self.base_url, username) def _list_paged(self, path, parameters=None, per_page=None): if parameters is None: parameters = {} else: parameters = dict(parameters.items()) if per_page: parameters["per_page"] = str(per_page) page = "1" while page: parameters["page"] = page response = self._api_request( "GET", path + "?" + "&".join(["{}={}".format(*item) for item in parameters.items()]), ) if response.status == 403: raise errors.PermissionDenied(response.text) if response.status != 200: _unexpected_status(path, response) page = response.getheader("X-Next-Page") yield from json.loads(response.data) def _list_merge_requests(self, author=None, project=None, state=None): if project is not None: path = "projects/{}/merge_requests".format(urlutils.quote(str(project), "")) else: path = "merge_requests" parameters = {} if state: parameters["state"] = state if author: parameters["author_username"] = urlutils.quote(author, "") return self._list_paged(path, parameters, per_page=DEFAULT_PAGE_SIZE) def _get_merge_request(self, project, merge_id): path = "projects/%s/merge_requests/%d" % ( urlutils.quote(str(project), ""), merge_id, ) response = self._api_request("GET", path) if response.status == 403: raise errors.PermissionDenied(response.text) if response.status != 200: _unexpected_status(path, response) return json.loads(response.data) def _list_projects(self, owner): path = "users/{}/projects".format(urlutils.quote(str(owner), "")) parameters = {} return self._list_paged(path, parameters, per_page=DEFAULT_PAGE_SIZE) def _update_merge_request(self, project_id, iid, mr): path = "projects/{}/merge_requests/{}".format( urlutils.quote(str(project_id), ""), iid ) response = self._api_request("PUT", path, fields=mr) if response.status == 200: return json.loads(response.data) if response.status == 409: raise GitLabConflict(json.loads(response.data).get("message")) if response.status == 403: raise errors.PermissionDenied(response.text) _unexpected_status(path, response) def _merge_mr(self, project_id, iid, kwargs): path = "projects/{}/merge_requests/{}/merge".format( urlutils.quote(str(project_id), ""), iid ) response = self._api_request("PUT", path, fields=kwargs) if response.status == 200: return json.loads(response.data) if response.status == 403: raise errors.PermissionDenied(response.text) _unexpected_status(path, response) def _post_merge_request_note(self, project_id, iid, kwargs): path = "projects/{}/merge_requests/{}/notes".format( urlutils.quote(str(project_id), ""), iid ) response = self._api_request("POST", path, fields=kwargs) if response.status == 201: json.loads(response.data) return if response.status == 403: raise errors.PermissionDenied(response.text) _unexpected_status(path, response) def _create_mergerequest( self, title, source_project_id, target_project_id, source_branch_name, target_branch_name, description, labels=None, allow_collaboration=False, ): path = "projects/{}/merge_requests".format(source_project_id) fields = { "title": title, "source_branch": source_branch_name, "target_branch": target_branch_name, "target_project_id": target_project_id, "description": description, "allow_collaboration": allow_collaboration, } if labels: fields["labels"] = labels response = self._api_request("POST", path, fields=fields) if response.status == 400: data = json.loads(response.data) raise GitLabError(data.get("message"), response) if response.status == 403: raise errors.PermissionDenied(response.text) if response.status == 409: raise GitLabConflict(json.loads(response.data).get("message")) if response.status == 422: data = json.loads(response.data) raise GitLabUnprocessable(data.get("error") or data.get("message"), data) if response.status != 201: _unexpected_status(path, response) return json.loads(response.data) def get_push_url(self, branch): (host, project_name, branch_name) = parse_gitlab_branch_url(branch) project = self._get_project(project_name) return gitlab_url_to_bzr_url(project["ssh_url_to_repo"], branch_name) def get_web_url(self, branch): (host, project_name, branch_name) = parse_gitlab_branch_url(branch) project = self._get_project(project_name) if branch_name: # TODO(jelmer): Use API to get this URL return project["web_url"] + "/-/tree/" + branch_name else: return project["web_url"] def publish_derived( self, local_branch, base_branch, name, project=None, owner=None, revision_id=None, overwrite=False, allow_lossy=True, tag_selector=None, ): if tag_selector is None: def tag_selector(t): return False (host, base_project_name, base_branch_name) = parse_gitlab_branch_url( base_branch ) if owner is None: owner = base_branch.get_config_stack().get("fork-namespace") if owner is None: owner = self.get_current_user() base_project = self._get_project(base_project_name) if project is None: project = base_project["path"] try: target_project = self._get_project("{}/{}".format(owner, project)) except NoSuchProject: target_project = self.fork_project( base_project["path_with_namespace"], owner=owner ) remote_repo_url = git_url_to_bzr_url(target_project["ssh_url_to_repo"]) remote_dir = controldir.ControlDir.open(remote_repo_url) try: push_result = remote_dir.push_branch( local_branch, revision_id=revision_id, overwrite=overwrite, name=name, tag_selector=tag_selector, ) except errors.NoRoundtrippingSupport: if not allow_lossy: raise push_result = remote_dir.push_branch( local_branch, revision_id=revision_id, overwrite=overwrite, name=name, lossy=True, tag_selector=tag_selector, ) public_url = gitlab_url_to_bzr_url(target_project["http_url_to_repo"], name) return push_result.target_branch, public_url def get_derived_branch( self, base_branch, name, project=None, owner=None, preferred_schemes=None ): (host, base_project, base_branch_name) = parse_gitlab_branch_url(base_branch) if owner is None: owner = self.get_current_user() if project is None: project = self._get_project(base_project)["path"] try: target_project = self._get_project("{}/{}".format(owner, project)) except NoSuchProject: raise errors.NotBranchError( "{}/{}/{}".format(self.base_url, owner, project) ) if preferred_schemes is None: preferred_schemes = ["git+ssh"] for scheme in preferred_schemes: if scheme == "git+ssh": gitlab_url = target_project["ssh_url_to_repo"] break elif scheme == "https": gitlab_url = target_project["http_url_to_repo"] break else: raise AssertionError return _mod_branch.Branch.open( gitlab_url_to_bzr_url(gitlab_url, name), possible_transports=[base_branch.user_transport], ) def get_proposer(self, source_branch, target_branch): return GitlabMergeProposalBuilder(self, source_branch, target_branch) def iter_proposals(self, source_branch, target_branch, status): (source_host, source_project_name, source_branch_name) = ( parse_gitlab_branch_url(source_branch) ) (target_host, target_project_name, target_branch_name) = ( parse_gitlab_branch_url(target_branch) ) if source_host != target_host: raise DifferentGitLabInstances(source_host, target_host) source_project = self._get_project(source_project_name) target_project = self._get_project(target_project_name) state = mp_status_to_status(status) for mr in self._list_merge_requests(project=target_project["id"], state=state): if ( mr["source_project_id"] != source_project["id"] or mr["source_branch"] != source_branch_name or mr["target_project_id"] != target_project["id"] or mr["target_branch"] != target_branch_name ): continue yield GitLabMergeProposal(self, mr) def hosts(self, branch): try: (host, project, branch_name) = parse_gitlab_branch_url(branch) except NotGitLabUrl: return False return self.base_hostname == host def _retrieve_user(self): if self._current_user: return try: response = self._api_request("GET", "user") except errors.UnexpectedHttpStatus as e: if e.code == 401: raise GitLabLoginMissing(self.base_url) raise if response.status == 200: self._current_user = json.loads(response.data) return if response.status == 401: if json.loads(response.data) == {"message": "401 Unauthorized"}: raise GitLabLoginMissing(self.base_url) else: raise GitlabLoginError(response.text) raise UnsupportedForge(self.base_url) @classmethod def probe_from_hostname(cls, hostname, possible_transports=None): base_url = "https://{}".format(hostname) credentials = get_credentials_by_url(base_url) if credentials is not None: transport = get_transport(base_url, possible_transports=possible_transports) instance = cls(transport, credentials.get("private_token")) instance._retrieve_user() return instance # We could potentially probe for e.g. /api/v4/metadata here # But none of the non-project APIs appear to be accessible without # authentication :-( raise UnsupportedForge(hostname) @classmethod def probe_from_url(cls, url, possible_transports=None): try: (host, project) = parse_gitlab_url(url) except NotGitLabUrl: raise UnsupportedForge(url) transport = get_transport( "https://{}".format(host), possible_transports=possible_transports ) credentials = get_credentials_by_url(transport.base) if credentials is not None: instance = cls(transport, credentials.get("private_token")) instance._retrieve_user() return instance try: resp = transport.request( "GET", "https://{}/api/v4/projects/{}".format( host, urlutils.quote(str(project), "") ), ) except errors.UnexpectedHttpStatus: raise UnsupportedForge(url) except errors.RedirectRequested: # GitLab doesn't send redirects for these URLs raise UnsupportedForge(url) else: if not resp.getheader("X-Gitlab-Feature-Category"): raise UnsupportedForge(url) if resp.status in (200, 401): raise GitLabLoginMissing("https://{}/".format(host)) raise UnsupportedForge(url) @classmethod def iter_instances(cls): for _name, credentials in iter_tokens(): yield cls( get_transport(credentials["url"]), private_token=credentials.get("private_token"), ) def iter_my_proposals(self, status="open", author=None): if author is None: author = self.get_current_user() state = mp_status_to_status(status) for mp in self._list_merge_requests(author=author, state=state): yield GitLabMergeProposal(self, mp) def iter_my_forks(self, owner: Optional[str] = None): if owner is None: owner = self.get_current_user() for project in self._list_projects(owner=owner): base_project = project.get("forked_from_project") if not base_project: continue yield project["path_with_namespace"] def get_proposal_by_url(self, url: str) -> GitLabMergeProposal: try: (host, project, merge_id) = parse_gitlab_merge_request_url(url) except NotGitLabUrl: raise UnsupportedForge(url) except NotMergeRequestUrl as e: if self.base_hostname == e.host: raise else: raise UnsupportedForge(url) if self.base_hostname != host: raise UnsupportedForge(url) project = self._get_project(project) mr = self._get_merge_request(project["path_with_namespace"], merge_id) return GitLabMergeProposal(self, mr) def delete_project(self, project): path = "projects/{}".format(urlutils.quote(str(project), "")) response = self._api_request("DELETE", path) if response.status == 404: raise NoSuchProject(project) if response.status != 202: _unexpected_status(path, response) class GitlabMergeProposalBuilder(MergeProposalBuilder): def __init__(self, gl, source_branch, target_branch): self.gl = gl self.source_branch = source_branch (self.source_host, self.source_project_name, self.source_branch_name) = ( parse_gitlab_branch_url(source_branch) ) self.target_branch = target_branch (self.target_host, self.target_project_name, self.target_branch_name) = ( parse_gitlab_branch_url(target_branch) ) if self.source_host != self.target_host: raise DifferentGitLabInstances(self.source_host, self.target_host) def get_infotext(self): """Determine the initial comment for the merge proposal.""" info = [] info.append("Gitlab instance: {}\n".format(self.target_host)) info.append("Source: {}\n".format(self.source_branch.user_url)) info.append("Target: {}\n".format(self.target_branch.user_url)) return "".join(info) def get_initial_body(self): """Get a body for the proposal for the user to modify. :return: a str or None. """ return None def create_proposal( self, description, title=None, reviewers=None, labels=None, prerequisite_branch=None, commit_message=None, work_in_progress=False, allow_collaboration=False, delete_source_after_merge: Optional[bool] = None, ): """Perform the submission.""" # https://docs.gitlab.com/ee/api/merge_requests.html#create-mr if prerequisite_branch is not None: raise PrerequisiteBranchUnsupported(self) # Note that commit_message is ignored, since Gitlab doesn't support it. source_project = self.gl._get_project(self.source_project_name) target_project = self.gl._get_project(self.target_project_name) if title is None: title = determine_title(description) if work_in_progress: title = "WIP: {}".format(title) # TODO(jelmer): Allow setting milestone field # TODO(jelmer): Allow setting squash field kwargs = { "title": title, "source_project_id": source_project["id"], "target_project_id": target_project["id"], "source_branch_name": self.source_branch_name, "target_branch_name": self.target_branch_name, "description": description, "allow_collaboration": allow_collaboration, } if delete_source_after_merge is not None: kwargs["should_remove_source_branch"] = delete_source_after_merge if labels: kwargs["labels"] = ",".join(labels) if reviewers: kwargs["assignee_ids"] = [] for reviewer in reviewers: if "@" in reviewer: user = self.gl._get_user_by_email(reviewer) else: user = self.gl._get_user(reviewer) kwargs["assignee_ids"].append(user["id"]) try: merge_request = self.gl._create_mergerequest(**kwargs) except GitLabConflict as e: self.gl._handle_merge_request_conflict( e.reason, self.source_branch.user_url, target_project["path_with_namespace"], ) except GitLabUnprocessable as e: if e.error == ["Source project is not a fork of the target project"]: raise SourceNotDerivedFromTarget(self.source_branch, self.target_branch) raise return GitLabMergeProposal(self.gl, merge_request) def register_gitlab_instance(shortname, url): """Register a gitlab instance. :param shortname: Short name (e.g. "gitlab") :param url: URL to the gitlab instance """ from breezy.bugtracker import ProjectIntegerBugTracker, tracker_registry tracker_registry.register( shortname, ProjectIntegerBugTracker(shortname, url + "/{project}/issues/{id}") )