mirror of
https://github.com/servo/servo.git
synced 2025-06-24 00:54:32 +01:00
Reorganize Servo's WPT Python scripts
This change moves all of Servo's WPT Python support scripts into one directory as they were previously scattered throughout the directory structure. This should allow more code reuse and make it easier to understand how everything fits together. The changes: - `tests/wpt/update` → `python/wpt/importer` - `etc/ci/upstream-wpt-changes/wptupstreamer` → `python/wpt/exporter` - `etc/ci/upstream-wpt-changes/test.py` → `python/wpt/test.py` - `etc/ci/upstream-wpt-changes/tests` → `python/wpt/tests` - `tests/wpt/servowpt.py` → - `python/wpt/update.py` - `python/wpt/run.py` - `tests/wpt/manifestupdate.py` → `python/wpt/manifestupdate.py` This change also removes - The ability to run the `update-wpt` and `test-wpt` commands without using `mach`. These didn't work very well, because it was difficult to get all of the wptrunner and mach dependencies installed outside of the Python virtualenv. It's simpler if they are always run through `mach`. - The old WPT change upstreaming script that was no longer used.
This commit is contained in:
parent
9acb9cc5cf
commit
e2cf3e8d1a
52 changed files with 237 additions and 888 deletions
285
python/wpt/exporter/__init__.py
Normal file
285
python/wpt/exporter/__init__.py
Normal file
|
@ -0,0 +1,285 @@
|
|||
# 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 dataclasses
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .common import \
|
||||
CLOSING_EXISTING_UPSTREAM_PR, \
|
||||
NO_SYNC_SIGNAL, \
|
||||
NO_UPSTREAMBLE_CHANGES_COMMENT, \
|
||||
OPENED_NEW_UPSTREAM_PR, \
|
||||
UPDATED_EXISTING_UPSTREAM_PR, \
|
||||
UPDATED_TITLE_IN_EXISTING_UPSTREAM_PR, \
|
||||
UPSTREAMABLE_PATH, \
|
||||
wpt_branch_name_from_servo_pr_number
|
||||
|
||||
from .github import GithubRepository, PullRequest
|
||||
from .step import \
|
||||
AsyncValue, \
|
||||
ChangePRStep, \
|
||||
CommentStep, \
|
||||
CreateOrUpdateBranchForPRStep, \
|
||||
MergePRStep, \
|
||||
OpenPRStep, \
|
||||
RemoveBranchForPRStep, \
|
||||
Step
|
||||
|
||||
|
||||
class LocalGitRepo:
|
||||
def __init__(self, path: str, sync: WPTSync):
|
||||
self.path = path
|
||||
self.sync = sync
|
||||
|
||||
def run_without_encoding(self, *args, env: dict = {}):
|
||||
command_line = ["git"] + list(args)
|
||||
logging.info(" → Execution (cwd='%s'): %s",
|
||||
self.path, " ".join(command_line))
|
||||
|
||||
env.setdefault("GIT_AUTHOR_EMAIL", self.sync.github_email)
|
||||
env.setdefault("GIT_COMMITTER_EMAIL", self.sync.github_email)
|
||||
env.setdefault("GIT_AUTHOR_NAME", self.sync.github_name)
|
||||
env.setdefault("GIT_COMMITTER_NAME", self.sync.github_name)
|
||||
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
command_line, cwd=self.path, env=env, stderr=subprocess.STDOUT
|
||||
)
|
||||
except subprocess.CalledProcessError as exception:
|
||||
logging.warning("Process execution failed with output:\n%s",
|
||||
exception.output.decode("utf-8", errors="surrogateescape"))
|
||||
raise exception
|
||||
|
||||
def run(self, *args, env: dict = {}):
|
||||
return (
|
||||
self
|
||||
.run_without_encoding(*args, env=env)
|
||||
.decode("utf-8", errors="surrogateescape")
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass()
|
||||
class SyncRun:
|
||||
sync: WPTSync
|
||||
servo_pr: PullRequest
|
||||
upstream_pr: AsyncValue[PullRequest]
|
||||
step_callback: Optional[Callable[[Step], None]]
|
||||
steps: list[Step] = dataclasses.field(default_factory=list)
|
||||
|
||||
def make_comment(self, template: str) -> str:
|
||||
upstream_pr = self.upstream_pr.value() if self.upstream_pr.has_value() else ""
|
||||
return template.format(
|
||||
upstream_pr=upstream_pr,
|
||||
servo_pr=self.servo_pr,
|
||||
)
|
||||
|
||||
def add_step(self, step) -> Optional[AsyncValue]:
|
||||
self.steps.append(step)
|
||||
return step.provides()
|
||||
|
||||
def run(self):
|
||||
# This loop always removes the first step and runs it, because
|
||||
# individual steps can modify the list of steps. For instance, if a
|
||||
# step fails, it might clear the remaining steps and replace them with
|
||||
# steps that report the error to GitHub.
|
||||
while self.steps:
|
||||
step = self.steps.pop(0)
|
||||
step.run(self)
|
||||
if self.step_callback:
|
||||
self.step_callback(step)
|
||||
|
||||
@staticmethod
|
||||
def clean_up_body_text(body: str) -> str:
|
||||
# Turn all bare or relative issue references into unlinked ones, so that
|
||||
# the PR doesn't inadvertently close or link to issues in the upstream
|
||||
# repository.
|
||||
return (
|
||||
re.sub(
|
||||
r"(^|\s)(\w*)#([1-9]\d*)",
|
||||
r"\g<1>\g<2>#<!-- nolink -->\g<3>",
|
||||
body,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
.split("\n---")[0]
|
||||
.split("<!-- Thank you for")[0]
|
||||
)
|
||||
|
||||
def prepare_body_text(self, body: str) -> str:
|
||||
return SyncRun.clean_up_body_text(body) + f"\nReviewed in {self.servo_pr}"
|
||||
|
||||
|
||||
@dataclasses.dataclass(kw_only=True)
|
||||
class WPTSync:
|
||||
servo_repo: str
|
||||
wpt_repo: str
|
||||
downstream_wpt_repo: str
|
||||
servo_path: str
|
||||
wpt_path: str
|
||||
github_api_token: str
|
||||
github_api_url: str
|
||||
github_username: str
|
||||
github_email: str
|
||||
github_name: str
|
||||
suppress_force_push: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
self.servo = GithubRepository(self, self.servo_repo)
|
||||
self.wpt = GithubRepository(self, self.wpt_repo)
|
||||
self.downstream_wpt = GithubRepository(self, self.downstream_wpt_repo)
|
||||
self.local_servo_repo = LocalGitRepo(self.servo_path, self)
|
||||
self.local_wpt_repo = LocalGitRepo(self.wpt_path, self)
|
||||
|
||||
def run(self, payload: dict, step_callback=None) -> bool:
|
||||
if "pull_request" not in payload:
|
||||
return True
|
||||
|
||||
pull_data = payload["pull_request"]
|
||||
if NO_SYNC_SIGNAL in pull_data.get("body", ""):
|
||||
return True
|
||||
|
||||
# Only look for an existing remote PR if the action is appropriate.
|
||||
logging.info("Processing '%s' action...", payload["action"])
|
||||
action = payload["action"]
|
||||
if action not in ["opened", "synchronize", "reopened", "edited", "closed"]:
|
||||
return True
|
||||
|
||||
if (
|
||||
action == "edited"
|
||||
and "title" not in payload["changes"]
|
||||
and "body" not in payload["changes"]
|
||||
):
|
||||
return True
|
||||
|
||||
try:
|
||||
servo_pr = self.servo.get_pull_request(pull_data["number"])
|
||||
downstream_wpt_branch = self.downstream_wpt.get_branch(
|
||||
wpt_branch_name_from_servo_pr_number(servo_pr.number)
|
||||
)
|
||||
upstream_pr = self.wpt.get_open_pull_request_for_branch(
|
||||
downstream_wpt_branch
|
||||
)
|
||||
if upstream_pr:
|
||||
logging.info(
|
||||
" → Detected existing upstream PR %s", upstream_pr)
|
||||
|
||||
run = SyncRun(self, servo_pr, AsyncValue(
|
||||
upstream_pr), step_callback)
|
||||
|
||||
pull_data = payload["pull_request"]
|
||||
if payload["action"] in ["opened", "synchronize", "reopened"]:
|
||||
self.handle_new_pull_request_contents(run, pull_data)
|
||||
elif payload["action"] == "edited":
|
||||
self.handle_edited_pull_request(run, pull_data)
|
||||
elif payload["action"] == "closed":
|
||||
self.handle_closed_pull_request(run, pull_data)
|
||||
|
||||
run.run()
|
||||
return True
|
||||
except Exception as exception:
|
||||
if isinstance(exception, subprocess.CalledProcessError):
|
||||
logging.error(exception.output)
|
||||
logging.error(json.dumps(payload))
|
||||
logging.error(exception, exc_info=True)
|
||||
return False
|
||||
|
||||
def handle_new_pull_request_contents(self, run: SyncRun, pull_data: dict):
|
||||
num_commits = pull_data["commits"]
|
||||
head_sha = pull_data["head"]["sha"]
|
||||
is_upstreamable = (
|
||||
len(
|
||||
self.local_servo_repo.run(
|
||||
"diff", head_sha, f"{head_sha}~{num_commits}", "--", UPSTREAMABLE_PATH
|
||||
)
|
||||
)
|
||||
> 0
|
||||
)
|
||||
logging.info(" → PR is upstreamable: '%s'", is_upstreamable)
|
||||
|
||||
title = pull_data['title']
|
||||
body = pull_data['body']
|
||||
if run.upstream_pr.has_value():
|
||||
if is_upstreamable:
|
||||
# In case this is adding new upstreamable changes to a PR that was closed
|
||||
# due to a lack of upstreamable changes, force it to be reopened.
|
||||
# Github refuses to reopen a PR that had a branch force pushed, so be sure
|
||||
# to do this first.
|
||||
run.add_step(ChangePRStep(
|
||||
run.upstream_pr.value(), "opened", title, body))
|
||||
# Push the relevant changes to the upstream branch.
|
||||
run.add_step(CreateOrUpdateBranchForPRStep(
|
||||
pull_data, run.servo_pr))
|
||||
run.add_step(CommentStep(
|
||||
run.servo_pr, UPDATED_EXISTING_UPSTREAM_PR))
|
||||
else:
|
||||
# Close the upstream PR, since would contain no changes otherwise.
|
||||
run.add_step(CommentStep(run.upstream_pr.value(),
|
||||
NO_UPSTREAMBLE_CHANGES_COMMENT))
|
||||
run.add_step(ChangePRStep(run.upstream_pr.value(), "closed"))
|
||||
run.add_step(RemoveBranchForPRStep(pull_data))
|
||||
run.add_step(CommentStep(
|
||||
run.servo_pr, CLOSING_EXISTING_UPSTREAM_PR))
|
||||
|
||||
elif is_upstreamable:
|
||||
# Push the relevant changes to a new upstream branch.
|
||||
branch = run.add_step(
|
||||
CreateOrUpdateBranchForPRStep(pull_data, run.servo_pr))
|
||||
|
||||
# Create a pull request against the upstream repository for the new branch.
|
||||
assert branch
|
||||
upstream_pr = run.add_step(OpenPRStep(
|
||||
branch, self.wpt, title, body,
|
||||
["servo-export", "do not merge yet"],
|
||||
))
|
||||
|
||||
assert upstream_pr
|
||||
run.upstream_pr = upstream_pr
|
||||
|
||||
# Leave a comment to the new pull request in the original pull request.
|
||||
run.add_step(CommentStep(run.servo_pr, OPENED_NEW_UPSTREAM_PR))
|
||||
|
||||
def handle_edited_pull_request(self, run: SyncRun, pull_data: dict):
|
||||
logging.info("Changing upstream PR title")
|
||||
if run.upstream_pr.has_value():
|
||||
run.add_step(ChangePRStep(
|
||||
run.upstream_pr.value(
|
||||
), "open", pull_data["title"], pull_data["body"]
|
||||
))
|
||||
run.add_step(CommentStep(
|
||||
run.servo_pr, UPDATED_TITLE_IN_EXISTING_UPSTREAM_PR))
|
||||
|
||||
def handle_closed_pull_request(self, run: SyncRun, pull_data: dict):
|
||||
logging.info("Processing closed PR")
|
||||
if not run.upstream_pr.has_value():
|
||||
# If we don't recognize this PR, it never contained upstreamable changes.
|
||||
return
|
||||
if pull_data["merged"]:
|
||||
# Since the upstreamable changes have now been merged locally, merge the
|
||||
# corresponding upstream PR.
|
||||
run.add_step(MergePRStep(
|
||||
run.upstream_pr.value(), ["do not merge yet"]))
|
||||
else:
|
||||
# If a PR with upstreamable changes is closed without being merged, we
|
||||
# don't want to merge the changes upstream either.
|
||||
run.add_step(ChangePRStep(run.upstream_pr.value(), "closed"))
|
||||
|
||||
# Always clean up our remote branch.
|
||||
run.add_step(RemoveBranchForPRStep(pull_data))
|
53
python/wpt/exporter/common.py
Normal file
53
python/wpt/exporter/common.py
Normal file
|
@ -0,0 +1,53 @@
|
|||
# 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=missing-docstring
|
||||
|
||||
UPSTREAMABLE_PATH = "tests/wpt/web-platform-tests/"
|
||||
NO_SYNC_SIGNAL = "[no-wpt-sync]"
|
||||
|
||||
OPENED_NEW_UPSTREAM_PR = (
|
||||
"🤖 Opened new upstream WPT pull request ({upstream_pr}) "
|
||||
"with upstreamable changes."
|
||||
)
|
||||
UPDATED_EXISTING_UPSTREAM_PR = (
|
||||
"📝 Transplanted new upstreamable changes to existing "
|
||||
"upstream WPT pull request ({upstream_pr})."
|
||||
)
|
||||
UPDATED_TITLE_IN_EXISTING_UPSTREAM_PR = (
|
||||
"✍ Updated existing upstream WPT pull request ({upstream_pr}) title and body."
|
||||
)
|
||||
CLOSING_EXISTING_UPSTREAM_PR = (
|
||||
"🤖 This change no longer contains upstreamable changes to WPT; closed existing "
|
||||
"upstream pull request ({upstream_pr})."
|
||||
)
|
||||
NO_UPSTREAMBLE_CHANGES_COMMENT = (
|
||||
"👋 Downstream pull request ({servo_pr}) no longer contains any upstreamable "
|
||||
"changes. Closing pull request without merging."
|
||||
)
|
||||
COULD_NOT_APPLY_CHANGES_DOWNSTREAM_COMMENT = (
|
||||
"🛠 These changes could not be applied onto the latest upstream WPT. "
|
||||
"Servo's copy of the Web Platform Tests may be out of sync."
|
||||
)
|
||||
COULD_NOT_APPLY_CHANGES_UPSTREAM_COMMENT = (
|
||||
"🛠 Changes from the source pull request ({servo_pr}) can no longer be "
|
||||
"cleanly applied. Waiting for a new version of these changes downstream."
|
||||
)
|
||||
COULD_NOT_MERGE_CHANGES_DOWNSTREAM_COMMENT = (
|
||||
"⛔ Failed to properly merge the upstream pull request ({upstream_pr}). "
|
||||
"Please address any CI issues and try to merge manually."
|
||||
)
|
||||
COULD_NOT_MERGE_CHANGES_UPSTREAM_COMMENT = (
|
||||
"⛔ The downstream PR has merged ({servo_pr}), but these changes could not "
|
||||
"be merged properly. Please address any CI issues and try to merge manually."
|
||||
)
|
||||
|
||||
|
||||
def wpt_branch_name_from_servo_pr_number(servo_pr_number):
|
||||
return f"servo_export_{servo_pr_number}"
|
178
python/wpt/exporter/github.py
Normal file
178
python/wpt/exporter/github.py
Normal file
|
@ -0,0 +1,178 @@
|
|||
# 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=missing-docstring
|
||||
|
||||
"""This modules contains some abstractions of GitHub repositories. It could one
|
||||
day be entirely replaced with something like PyGithub."""
|
||||
|
||||
# This allows using types that are defined later in the file.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import urllib
|
||||
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
import requests
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import WPTSync
|
||||
|
||||
USER_AGENT = "Servo web-platform-test sync service"
|
||||
TIMEOUT = 30 # 30 seconds
|
||||
|
||||
|
||||
def authenticated(sync: WPTSync, method, url, json=None) -> requests.Response:
|
||||
logging.info(" → Request: %s %s", method, url)
|
||||
if json:
|
||||
logging.info(" → Request JSON: %s", json)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {sync.github_api_token}",
|
||||
"User-Agent": USER_AGENT,
|
||||
}
|
||||
|
||||
url = urllib.parse.urljoin(sync.github_api_url, url)
|
||||
response = requests.request(
|
||||
method, url, headers=headers, json=json, timeout=TIMEOUT
|
||||
)
|
||||
if int(response.status_code / 100) != 2:
|
||||
raise ValueError(
|
||||
f"Got unexpected {response.status_code} response: {response.text}"
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class GithubRepository:
|
||||
"""
|
||||
This class allows interacting with a single GitHub repository.
|
||||
"""
|
||||
|
||||
def __init__(self, sync: WPTSync, repo: str):
|
||||
self.sync = sync
|
||||
self.repo = repo
|
||||
self.org = repo.split("/")[0]
|
||||
self.pulls_url = f"repos/{self.repo}/pulls"
|
||||
|
||||
def __str__(self):
|
||||
return self.repo
|
||||
|
||||
def get_pull_request(self, number: int) -> PullRequest:
|
||||
return PullRequest(self, number)
|
||||
|
||||
def get_branch(self, name: str) -> GithubBranch:
|
||||
return GithubBranch(self, name)
|
||||
|
||||
def get_open_pull_request_for_branch(
|
||||
self, branch: GithubBranch
|
||||
) -> Optional[PullRequest]:
|
||||
"""If this repository has an open pull request with the
|
||||
given source head reference targeting the master branch,
|
||||
return the first matching pull request, otherwise return None."""
|
||||
|
||||
params = "+".join([
|
||||
"is:pr",
|
||||
"state:open",
|
||||
f"repo:{self.repo}",
|
||||
f"author:{branch.repo.org}",
|
||||
f"head:{branch.name}",
|
||||
])
|
||||
response = authenticated(self.sync, "GET", f"search/issues?q={params}")
|
||||
if int(response.status_code / 100) != 2:
|
||||
return None
|
||||
|
||||
json = response.json()
|
||||
if not isinstance(json, dict) or \
|
||||
"total_count" not in json or \
|
||||
"items" not in json:
|
||||
raise ValueError(
|
||||
f"Got unexpected response from GitHub search: {response.text}"
|
||||
)
|
||||
|
||||
if json["total_count"] < 1:
|
||||
return None
|
||||
|
||||
return self.get_pull_request(json["items"][0]["number"])
|
||||
|
||||
def open_pull_request(self, branch: GithubBranch, title: str, body: str):
|
||||
data = {
|
||||
"title": title,
|
||||
"head": branch.get_pr_head_reference_for_repo(self),
|
||||
"base": "master",
|
||||
"body": body,
|
||||
"maintainer_can_modify": False,
|
||||
}
|
||||
response = authenticated(self.sync, "POST", self.pulls_url, json=data)
|
||||
return self.get_pull_request(response.json()["number"])
|
||||
|
||||
|
||||
class GithubBranch:
|
||||
def __init__(self, repo: GithubRepository, branch_name: str):
|
||||
self.repo = repo
|
||||
self.name = branch_name
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.repo}/{self.name}"
|
||||
|
||||
def get_pr_head_reference_for_repo(self, other_repo: GithubRepository) -> str:
|
||||
"""Get the head reference to use in pull requests for the given repository.
|
||||
If the organization is the same this is just `<branch>` otherwise
|
||||
it will be `<org>:<branch>`."""
|
||||
if self.repo.org == other_repo.org:
|
||||
return self.name
|
||||
return f"{self.repo.org}:{self.name}"
|
||||
|
||||
|
||||
class PullRequest:
|
||||
"""
|
||||
This class allows interacting with a single pull request on GitHub.
|
||||
"""
|
||||
|
||||
def __init__(self, repo: GithubRepository, number: int):
|
||||
self.repo = repo
|
||||
self.context = repo.sync
|
||||
self.number = number
|
||||
self.base_url = f"repos/{self.repo.repo}/pulls/{self.number}"
|
||||
self.base_issues_url = f"repos/{self.repo.repo}/issues/{self.number}"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.repo}#{self.number}"
|
||||
|
||||
def api(self, *args, **kwargs) -> requests.Response:
|
||||
return authenticated(self.context, *args, **kwargs)
|
||||
|
||||
def leave_comment(self, comment: str):
|
||||
return self.api(
|
||||
"POST", f"{self.base_issues_url}/comments", json={"body": comment}
|
||||
)
|
||||
|
||||
def change(
|
||||
self,
|
||||
state: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
body: Optional[str] = None,
|
||||
):
|
||||
data = {}
|
||||
if title:
|
||||
data["title"] = title
|
||||
if body:
|
||||
data["body"] = body
|
||||
if state:
|
||||
data["state"] = state
|
||||
return self.api("PATCH", self.base_url, json=data)
|
||||
|
||||
def remove_label(self, label: str):
|
||||
self.api("DELETE", f"{self.base_issues_url}/labels/{label}")
|
||||
|
||||
def add_labels(self, labels: list[str]):
|
||||
self.api("POST", f"{self.base_issues_url}/labels", json=labels)
|
||||
|
||||
def merge(self):
|
||||
self.api("PUT", f"{self.base_url}/merge", json={"merge_method": "rebase"})
|
313
python/wpt/exporter/step.py
Normal file
313
python/wpt/exporter/step.py
Normal file
|
@ -0,0 +1,313 @@
|
|||
# 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 = []
|
||||
for sha in 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)
|
Loading…
Add table
Add a link
Reference in a new issue