mergerequestcreator.py 8.37 KB
Newer Older
Jonah Brüchert's avatar
Jonah Brüchert committed
1
2
3
4
"""
Module containing classes for creating merge requests
"""

Jonah Brüchert's avatar
Jonah Brüchert committed
5
6
7
8
# SPDX-FileCopyrightText: 2020 Jonah Brüchert <jbb@kaidan.im>
#
# SPDX-License-Identifier: GPL-2.0-or-later

9
import argparse
10
11
import re
import os
12
13
import subprocess
import sys
14
15
16

from typing import List, Any

Méven Car's avatar
Méven Car committed
17
from git import Remote, IndexFile, PushInfo
18

19
from gitlab.v4.objects import Project, ProjectMergeRequest
20
from gitlab.exceptions import GitlabCreateError, GitlabGetError
Jonah Brüchert's avatar
Jonah Brüchert committed
21
22

from lab.repositoryconnection import RepositoryConnection
Jonah Brüchert's avatar
Jonah Brüchert committed
23
from lab.config import RepositoryConfig, Workflow
24
from lab.utils import Utils, LogType
Jonah Brüchert's avatar
Jonah Brüchert committed
25
26
from lab.editorinput import EditorInput

Jonah Brüchert's avatar
Jonah Brüchert committed
27

28
29
30
def parser(
    subparsers: argparse._SubParsersAction,  # pylint: disable=protected-access
) -> argparse.ArgumentParser:
31
32
33
34
35
36
37
    """
    Subparser for merge request creation command
    :param subparsers: subparsers object from global parser
    :return: merge request creation subparser
    """
    create_parser: argparse.ArgumentParser = subparsers.add_parser(
        "mr", help="Create a new merge request for the current branch", aliases=["diff"]
38
    )
39
40
    create_parser.add_argument(
        "--target-branch", help="Use different target branch than master", default="master",
41
    )
42
    return create_parser
43
44


45
46
47
48
49
def run(args: argparse.Namespace) -> None:
    """
    run merge request creation command
    :param args: parsed arguments
    """
Jonah Brüchert's avatar
Jonah Brüchert committed
50
    # To fork or not to fork
Jonah Brüchert's avatar
Jonah Brüchert committed
51
    fork: bool = (RepositoryConfig().workflow() == Workflow.Fork)
Jonah Brüchert's avatar
Jonah Brüchert committed
52
53
    creator: MergeRequestCreator = MergeRequestCreator(args.target_branch, fork)
    creator.check()
54
    creator.commit()
Jonah Brüchert's avatar
Jonah Brüchert committed
55

Jonah Brüchert's avatar
Jonah Brüchert committed
56
    if fork:
Jonah Brüchert's avatar
Jonah Brüchert committed
57
58
        creator.fork()

Jonah Brüchert's avatar
Jonah Brüchert committed
59
60
    creator.push()
    creator.create_mr()
61
62


Jonah Brüchert's avatar
Jonah Brüchert committed
63
class MergeRequestCreator(RepositoryConnection):
Jonah Brüchert's avatar
Jonah Brüchert committed
64
65
66
67
68
    """
    Class for creating a merge request,
    including forking the remote repository and pushing to the fork.
    """

Jonah Brüchert's avatar
Jonah Brüchert committed
69
70
    # private
    __remote_fork: Project
71
    __target_branch: str
Jonah Brüchert's avatar
Jonah Brüchert committed
72
    __fork: bool
Jonah Brüchert's avatar
Jonah Brüchert committed
73

Jonah Brüchert's avatar
Jonah Brüchert committed
74
    def __init__(self, target_branch: str, fork: bool) -> None:
Jonah Brüchert's avatar
Jonah Brüchert committed
75
        RepositoryConnection.__init__(self)
76
        self.__target_branch = target_branch
Jonah Brüchert's avatar
Jonah Brüchert committed
77
        self.__fork = fork
Jonah Brüchert's avatar
Jonah Brüchert committed
78

Jonah Brüchert's avatar
Jonah Brüchert committed
79
    def check(self) -> None:
80
81
82
83
        """
        Run some sanity checks and warn the user if necessary
        """
        if (
84
            not self._local_repo.active_branch.name.startswith("work/")
Jonah Brüchert's avatar
Jonah Brüchert committed
85
            and not self.__fork
86
            and "invent.kde.org" in self._connection.url
87
88
89
90
91
92
93
94
95
96
97
        ):
            Utils.log(
                LogType.Warning,
                "Pushing to the upstream repository, but the branch name doesn't start with work/.",
            )
            print(
                "This is not recommended on KDE infrastructure,",
                "as it doesn't allow to rebase or force-push the branch.",
                "To cancel, please press Ctrl + C.",
            )

98
99
100
101
102
    def commit(self) -> None:
        """
        Determine whether there are uncommitted changes, and ask the user what to do about them
        """

103
        index: IndexFile = self._local_repo.index
104
105
106
107
108
109
110
111
112
113
114
115
116
        if len(index.diff("HEAD")) > 0:
            Utils.log(LogType.Info, "You have staged but uncommited changes.")
            create_commit: bool = Utils.ask_bool("do you want to create a new commit?")

            if create_commit:
                # We can't use self.local_repo().git.commit() here, as it would
                # start the editor in the background
                try:
                    subprocess.check_call(["git", "commit"])
                except subprocess.CalledProcessError:
                    Utils.log(LogType.Error, "git exited with an error code")
                    sys.exit(1)

Jonah Brüchert's avatar
Jonah Brüchert committed
117
    def fork(self) -> None:
Jonah Brüchert's avatar
Jonah Brüchert committed
118
119
120
121
        """
        Try to create a fork of the remote repository.
        If the fork already exists, no new fork will be created.
        """
122

123
        if "fork" in self._local_repo.remotes:
124
            # Fork already exists
125
            fork_str_id: str = Utils.str_id_for_url(self._local_repo.remotes.fork.url)
126
127
128
129

            # Try to retrieve the remote project object, if it doesn't exist on the server,
            # go on with the logic to create a new fork.
            try:
130
                self.__remote_fork = self._connection.projects.get(fork_str_id)
131
132
133
                return
            except GitlabGetError:
                pass
134

Jonah Brüchert's avatar
Jonah Brüchert committed
135
        try:
136
            self.__remote_fork = self._remote_project.forks.create({})
137
138
139
140

            # WORKAROUND: the return of create() is unreliable,
            # and sometimes doesn't allow to create merge requests,
            # so request a fresh project object.
141
            self.__remote_fork = self._connection.projects.get(self.__remote_fork.id)
142

143
            self._local_repo.create_remote("fork", url=self.__remote_fork.ssh_url_to_repo)
Jonah Brüchert's avatar
Jonah Brüchert committed
144
        except GitlabCreateError:
145
146
147
148
149
150
            Utils.log(
                LogType.Info,
                "Fork exists, but no fork remote exists locally, trying to guess the url",
            )
            # Detect ssh url
            url = Utils.ssh_url_from_http(
151
                self._connection.user.web_url + "/" + self._remote_project.path
152
153
            )

154
            self._local_repo.create_remote("fork", url=url)
155

156
157
            str_id: str = Utils.str_id_for_url(self._local_repo.remotes.fork.url)
            self.__remote_fork = self._connection.projects.get(str_id)
Jonah Brüchert's avatar
Jonah Brüchert committed
158

Jonah Brüchert's avatar
Jonah Brüchert committed
159
    def push(self) -> None:
Jonah Brüchert's avatar
Jonah Brüchert committed
160
161
162
        """
        pushes the local repository to the fork remote
        """
163
        remote: Remote
Méven Car's avatar
Méven Car committed
164
        info: PushInfo
Jonah Brüchert's avatar
Jonah Brüchert committed
165
        if self.__fork:
166
            remote = self._local_repo.remotes.fork
Méven Car's avatar
Méven Car committed
167
            info = remote.push(force=True)[0]
Jonah Brüchert's avatar
Jonah Brüchert committed
168
        else:
169
            remote = self._local_repo.remotes.origin
Méven Car's avatar
Méven Car committed
170
            info = remote.push(refspec=self._local_repo.head, force=True)[0]
Jonah Brüchert's avatar
Jonah Brüchert committed
171

Méven Car's avatar
Méven Car committed
172
173
        self._local_repo.active_branch.set_tracking_branch(info.remote_ref)
        
174
175
    def __upload_assets(self, text: str) -> str:
        """
176
        Scans the text for local file paths, uploads the files and returns
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
        the text modified to load the files from the uploaded urls
        """
        find_expr = re.compile(r"!\[[^\[\(]*\]\([^\[\(]*\)")
        extract_expr = re.compile(r"(?<=\().+?(?=\))")

        matches: List[Any] = find_expr.findall(text)

        output_text: str = text

        for match in matches:
            image = extract_expr.findall(match)[0]

            if not image.startswith("http"):
                Utils.log(LogType.Info, "Uploading", image)

                filename: str = os.path.basename(image)
193
                try:
194
                    uploaded_file = self._remote_project.upload(filename, filepath=image)
195
196
197
198
                    output_text = output_text.replace(image, uploaded_file["url"])
                except FileNotFoundError:
                    Utils.log(LogType.Warning, "Failed to upload image", image)
                    print("The file does not exist.")
199
200
201

        return output_text

Jonah Brüchert's avatar
Jonah Brüchert committed
202
    def create_mr(self) -> None:
Jonah Brüchert's avatar
Jonah Brüchert committed
203
204
205
        """
        Creates a merge request with the changes from the current branch
        """
206

207
208
        mrs: List[ProjectMergeRequest] = self._remote_project.mergerequests.list(
            source_branch=self._local_repo.active_branch.name,
209
            target_branch=self.__target_branch,
210
            target_project_id=self._remote_project.id,
211
212
213
214
215
216
        )

        if len(mrs) > 0:
            merge_request = mrs[0]
            Utils.log(
                LogType.Info,
217
                'Updating existing merge request "{}" at: {}'.format(
218
219
220
221
222
                    merge_request.title, merge_request.web_url
                ),
            )
            return

223
        e_input = EditorInput(
224
225
            placeholder_title=self._local_repo.head.commit.summary,
            placeholder_body=self._local_repo.head.commit.message.split("\n", 1)[1].strip(),
226
            extra_text="The markdown syntax for embedding images "
227
            + "![description](/path/to/file) can be used to upload images.",
228
        )
Jonah Brüchert's avatar
Jonah Brüchert committed
229

230
        project: Project = self.__remote_fork if self.__fork else self._remote_project
231

232
233
        merge_request = project.mergerequests.create(
            {
234
                "source_branch": self._local_repo.active_branch.name,
235
236
237
                "target_branch": self.__target_branch,
                "title": e_input.title,
                "description": self.__upload_assets(e_input.body),
238
                "target_project_id": self._remote_project.id,
239
240
241
242
243
                "allow_maintainer_to_push": True,
                "remove_source_branch": True,
            }
        )
        Utils.log(LogType.Info, "Created merge request at", merge_request.web_url)