| Server IP : 217.160.0.244 / Your IP : 216.73.217.138 Web Server : Apache System : Linux infong-eu155 4.4.400-icpu-108 #2 SMP Wed Feb 11 11:51:01 UTC 2026 x86_64 User : u100174116 ( 6746176) PHP Version : 8.5.10 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : OFF | Pkexec : OFF Directory : /lib/python3/dist-packages/breezy/plugins/gitlab/ |
Upload File : |
# 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}")
)