aboutsummaryrefslogtreecommitdiffstats
path: root/python/wpt/exporter/step.py
blob: 9be91d65730794c4a09819196a2b134dc6cfcecc (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# Copyright 2023 The Servo Project Developers. See the COPYRIGHT
# file at the top-level directory of this distribution.
#
# Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
# http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
# <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
# option. This file may not be copied, modified, or distributed
# except according to those terms.

# pylint: disable=broad-except
# pylint: disable=dangerous-default-value
# pylint: disable=fixme
# pylint: disable=missing-docstring

# This allows using types that are defined later in the file.
from __future__ import annotations

import logging
import os
import textwrap

from typing import TYPE_CHECKING, Generic, Optional, TypeVar

from .common import COULD_NOT_APPLY_CHANGES_DOWNSTREAM_COMMENT
from .common import COULD_NOT_APPLY_CHANGES_UPSTREAM_COMMENT
from .common import COULD_NOT_MERGE_CHANGES_DOWNSTREAM_COMMENT
from .common import COULD_NOT_MERGE_CHANGES_UPSTREAM_COMMENT
from .common import UPSTREAMABLE_PATH
from .common import wpt_branch_name_from_servo_pr_number
from .github import GithubBranch, GithubRepository, PullRequest

if TYPE_CHECKING:
    from . import SyncRun, WPTSync

PATCH_FILE_NAME = "tmp.patch"


class Step:
    def __init__(self, name):
        self.name = name

    def provides(self) -> Optional[AsyncValue]:
        return None

    def run(self, _: SyncRun):
        return


T = TypeVar('T')


class AsyncValue(Generic[T]):
    def __init__(self, value: Optional[T] = None):
        self._value = value

    def resolve(self, value: T):
        self._value = value

    def value(self) -> T:
        assert self._value is not None
        return self._value

    def has_value(self):
        return self._value is not None


class CreateOrUpdateBranchForPRStep(Step):
    def __init__(self, pull_data: dict, pull_request: PullRequest):
        Step.__init__(self, "CreateOrUpdateBranchForPRStep")
        self.pull_data = pull_data
        self.pull_request = pull_request
        self.branch: AsyncValue[GithubBranch] = AsyncValue()

    def provides(self):
        return self.branch

    def run(self, run: SyncRun):
        try:
            commits = self._get_upstreamable_commits_from_local_servo_repo(
                run.sync)
            branch_name = self._create_or_update_branch_for_pr(run, commits)
            branch = run.sync.downstream_wpt.get_branch(branch_name)

            self.branch.resolve(branch)
            self.name += f":{len(commits)}:{branch}"
        except Exception as exception:
            logging.info("Could not apply changes to upstream WPT repository.")
            logging.info(exception, exc_info=True)

            run.steps = []
            run.add_step(CommentStep(
                self.pull_request, COULD_NOT_APPLY_CHANGES_DOWNSTREAM_COMMENT
            ))
            if run.upstream_pr.has_value():
                run.add_step(CommentStep(
                    run.upstream_pr.value(), COULD_NOT_APPLY_CHANGES_UPSTREAM_COMMENT
                ))

    def _get_upstreamable_commits_from_local_servo_repo(self, sync: WPTSync):
        local_servo_repo = sync.local_servo_repo
        number_of_commits = self.pull_data["commits"]
        pr_head = self.pull_data["head"]["sha"]
        commit_shas = local_servo_repo.run(
            "log", "--pretty=%H", pr_head, f"-{number_of_commits}"
        ).splitlines()

        filtered_commits = []
        # We must iterate the commits in reverse to ensure we apply older changes first,
        # in case later commits would conflict.
        for sha in reversed(commit_shas):
            # Specifying the path here does a few things. First, it excludes any
            # changes that do not touch WPT files at all. Secondly, when a file is
            # moved in or out of the WPT directory the filename which is outside the
            # directory becomes /dev/null and the change becomes an addition or
            # deletion. This makes the patch usable on the WPT repository itself.
            # TODO: If we could cleverly parse and manipulate the full commit diff
            # we could avoid cloning the servo repository altogether and only
            # have to fetch the commit diffs from GitHub.
            # NB: The output of git show might include binary files or non-UTF8 text,
            # so store the content of the diff as a `bytes`.
            diff = local_servo_repo.run_without_encoding(
                "show", "--binary", "--format=%b", sha, "--", UPSTREAMABLE_PATH
            )

            # Retrieve the diff of any changes to files that are relevant
            if diff:
                # Create an object that contains everything necessary to transplant this
                # commit to another repository.
                filtered_commits += [
                    {
                        "author": local_servo_repo.run(
                            "show", "-s", "--pretty=%an <%ae>", sha
                        ),
                        "message": local_servo_repo.run(
                            "show", "-s", "--pretty=%B", sha
                        ),
                        "diff": diff,
                    }
                ]
        return filtered_commits

    def _apply_filtered_servo_commit_to_wpt(self, run: SyncRun, commit: dict):
        patch_path = os.path.join(run.sync.wpt_path, PATCH_FILE_NAME)
        strip_count = UPSTREAMABLE_PATH.count("/") + 1

        try:
            with open(patch_path, "wb") as file:
                file.write(commit["diff"])
            run.sync.local_wpt_repo.run(
                "apply", PATCH_FILE_NAME, "-p", str(strip_count)
            )
        finally:
            # Ensure the patch file is not added with the other changes.
            os.remove(patch_path)

        run.sync.local_wpt_repo.run("add", "--all")
        run.sync.local_wpt_repo.run(
            "commit", "--message", commit["message"], "--author", commit["author"]
        )

    def _create_or_update_branch_for_pr(
        self, run: SyncRun, commits: list[dict], pre_commit_callback=None
    ):
        branch_name = wpt_branch_name_from_servo_pr_number(
            self.pull_data["number"])
        try:
            # Create a new branch with a unique name that is consistent between
            # updates of the same PR.
            run.sync.local_wpt_repo.run("checkout", "-b", branch_name)

            for commit in commits:
                self._apply_filtered_servo_commit_to_wpt(run, commit)

            if pre_commit_callback:
                pre_commit_callback()

            # Push the branch upstream (forcing to overwrite any existing changes).
            if not run.sync.suppress_force_push:

                # In order to push to our downstream branch we need to ensure that
                # the local repository isn't a shallow clone. Shallow clones are
                # commonly created by GitHub actions.
                run.sync.local_wpt_repo.run("fetch", "--unshallow", "origin")

                user = run.sync.github_username
                token = run.sync.github_api_token
                repo = run.sync.downstream_wpt_repo
                remote_url = f"https://{user}:{token}@github.com/{repo}.git"
                run.sync.local_wpt_repo.run(
                    "push", "-f", remote_url, branch_name)

            return branch_name
        finally:
            try:
                run.sync.local_wpt_repo.run("checkout", "master")
                run.sync.local_wpt_repo.run("branch", "-D", branch_name)
            except Exception:
                pass


class RemoveBranchForPRStep(Step):
    def __init__(self, pull_request):
        Step.__init__(self, "RemoveBranchForPRStep")
        self.branch_name = wpt_branch_name_from_servo_pr_number(
            pull_request["number"])

    def run(self, run: SyncRun):
        self.name += f":{run.sync.downstream_wpt.get_branch(self.branch_name)}"
        logging.info("  -> Removing branch used for upstream PR")
        if not run.sync.suppress_force_push:
            user = run.sync.github_username
            token = run.sync.github_api_token
            repo = run.sync.downstream_wpt_repo
            remote_url = f"https://{user}:{token}@github.com/{repo}.git"
            run.sync.local_wpt_repo.run("push", remote_url, "--delete",
                                        self.branch_name)


class ChangePRStep(Step):
    def __init__(
        self,
        pull_request: PullRequest,
        state: str,
        title: Optional[str] = None,
        body: Optional[str] = None,
    ):
        name = f"ChangePRStep:{pull_request}:{state}"
        if title:
            name += f":{title}"

        Step.__init__(self, name)
        self.pull_request = pull_request
        self.state = state
        self.title = title
        self.body = body

    def run(self, run: SyncRun):
        body = self.body
        if body:
            body = run.prepare_body_text(body)
            self.name += (
                f':{textwrap.shorten(body, width=20, placeholder="...")}[{len(body)}]'
            )

        self.pull_request.change(state=self.state, title=self.title, body=body)


class MergePRStep(Step):
    def __init__(self, pull_request: PullRequest, labels_to_remove: list[str] = []):
        Step.__init__(self, f"MergePRStep:{pull_request}")
        self.pull_request = pull_request
        self.labels_to_remove = labels_to_remove

    def run(self, run: SyncRun):
        for label in self.labels_to_remove:
            self.pull_request.remove_label(label)
        try:
            self.pull_request.merge()
        except Exception as exception:
            logging.warning("Could not merge PR (%s).", self.pull_request)
            logging.warning(exception, exc_info=True)

            run.steps = []
            run.add_step(CommentStep(
                self.pull_request, COULD_NOT_MERGE_CHANGES_UPSTREAM_COMMENT
            ))
            run.add_step(CommentStep(
                run.servo_pr, COULD_NOT_MERGE_CHANGES_DOWNSTREAM_COMMENT
            ))
            self.pull_request.add_labels(["stale-servo-export"])


class OpenPRStep(Step):
    def __init__(
        self,
        source_branch: AsyncValue[GithubBranch],
        target_repo: GithubRepository,
        title: str,
        body: str,
        labels: list[str],
    ):
        Step.__init__(self, "OpenPRStep")
        self.title = title
        self.body = body
        self.source_branch = source_branch
        self.target_repo = target_repo
        self.new_pr: AsyncValue[PullRequest] = AsyncValue()
        self.labels = labels

    def provides(self):
        return self.new_pr

    def run(self, run: SyncRun):
        pull_request = self.target_repo.open_pull_request(
            self.source_branch.value(), self.title, run.prepare_body_text(self.body)
        )

        if self.labels:
            pull_request.add_labels(self.labels)

        self.new_pr.resolve(pull_request)

        self.name += f":{self.source_branch.value()}{self.new_pr.value()}"


class CommentStep(Step):
    def __init__(self, pull_request: PullRequest, comment_template: str):
        Step.__init__(self, "CommentStep")
        self.pull_request = pull_request
        self.comment_template = comment_template

    def run(self, run: SyncRun):
        comment = run.make_comment(self.comment_template)
        self.name += f":{self.pull_request}:{comment}"
        self.pull_request.leave_comment(comment)