summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorDaniel Smith <daniel.smith@qt.io>2022-06-22 13:26:56 +0200
committerDaniel Smith <daniel.smith@qt.io>2022-08-29 08:25:22 +0200
commitd3bb4633b0fb0cd840a1909b680aa9bc7c86ae2a (patch)
tree7d417f4b3b7fd0d05774ec77ea72f8701ced650f /scripts
parent896e727d65f9b8ebff6ec22dc383942503cf8b7f (diff)
downloadqtqa-d3bb4633b0fb0cd840a1909b680aa9bc7c86ae2a.tar.gz
Add a script to warn about missing pick-to branch targets
After branching Qt, it is necessary to ensure that all outstanding changes which should target the new branch do so. This script performs an on-demand sanity-check of the Pick-to commit message footer on open changes. The script checks all major branches for outstanding changes, so no configuration following branching is necessary. If a change targets major.current-1, but not major.current branch, a -1 sanity warning with relevant comment will be posted. See also sanity bot update: 125f1520ee41b018888e2e2652a0b1c299f93550 Task-number: QTQAINFRA-5036 Change-Id: I957076ee207d05bbf9324e6f2f637ad93747d51f Reviewed-by: Edward Welbourne <edward.welbourne@qt.io>
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/qt/warn_cherry-pick_branches.py244
1 files changed, 244 insertions, 0 deletions
diff --git a/scripts/qt/warn_cherry-pick_branches.py b/scripts/qt/warn_cherry-pick_branches.py
new file mode 100755
index 0000000..3ae1a5a
--- /dev/null
+++ b/scripts/qt/warn_cherry-pick_branches.py
@@ -0,0 +1,244 @@
+#!/usr/bin/env python3
+
+# Copyright (C) 2022 The Qt Company Ltd.
+# Contact: https://www.qt.io/licensing/
+#
+# You may use this file under the terms of the 3-clause BSD license.
+# See the file LICENSE in qt/qtrepotools for details.
+#
+
+import argparse
+import json
+import os
+import getpass
+import re
+import urllib.parse
+
+import requests
+
+usage_text = """
+Warn about cherry-picking changes which do not contain the latest branch.
+
+This script should be run after branching Qt to a new stable branch version.
+It is expected that changes committed to the dev branch which contain a
+"Pick-to:" footer in the commit should target the latest stable branch.
+After a branching operation is complete, the Pick-to: footer must be
+updated to target the current stable branch in addition to the now
+latest-1 stable branch.
+
+This script will give a -1 and post a comment when an open change targets the
+latest-1 stable branch and is missing the newly branched stable branch.
+The script will also examine merged changes since the branching occurred for
+this type of discrepancy.
+
+Requires package: "python-requests", installable via `pip3 install requests`
+"""
+
+
+def trim_response(text) -> str:
+ """Trim off Gerrit's magic prefix from JSON responses"""
+ return text.removeprefix(")]}'")
+
+
+class Gerrit:
+ def __init__(self):
+ from requests.auth import HTTPBasicAuth
+ if not os.environ.get("GERRIT_USERNAME") or not os.environ.get("GERRIT_PASS"):
+ print('Notice: You can set your username and password via environment variables'
+ ' "GERRIT_USERNAME" and "GERRIT_PASSWORD"')
+ self.gerrit_user = os.environ.get("GERRIT_USERNAME") or input("Gerrit Username: ")
+ gerrit_pass = os.environ.get("GERRIT_PASS") or getpass.getpass("Gerrit Password: ")
+ self.auth = HTTPBasicAuth(self.gerrit_user, gerrit_pass)
+ if self.get("projects").status_code == 401:
+ print("Gerrit Authorization failure. Please ensure your credentials are correct.")
+ exit(1)
+
+ @property
+ def _gerrit_user(self):
+ return self.gerrit_user
+
+ @staticmethod
+ def __to_url(tail):
+ return f"https://codereview.qt-project.org/a/{tail}"
+
+ def get(self, query):
+ return requests.get(self.__to_url(query), auth=self.auth)
+
+ def post(self, query, data):
+ return requests.post(self.__to_url(query), json=data, auth=self.auth)
+
+
+class Major:
+ """A Major branch contains numerical, ascending-ordered stable branches"""
+ def __init__(self, major_ver: int, stable_branches: list[int]):
+ self.major_ver = major_ver
+ self.stable_branches = sorted(stable_branches)
+ self.latest: int = self.stable_branches[-1] if stable_branches else -1
+ self.previous: int = -1
+
+ @property
+ def latest_name(self):
+ return f"{self.major_ver}.{self.latest}"
+
+ @property
+ def previous_name(self):
+ return f"{self.major_ver}.{self.previous}"
+
+ def append(self, item: int):
+ if item not in self.stable_branches:
+ self.stable_branches.append(item)
+ self.stable_branches = sorted(self.stable_branches)
+ self.latest = self.stable_branches[-1]
+ if len(self.stable_branches) >= 2:
+ self.previous = self.stable_branches[-2]
+
+ def __repr__(self):
+ return ", ".join([f"{self.major_ver}.{stable}" for stable in self.stable_branches])
+
+
+class Project:
+ """A project contains Major branches which in turn have stable branches."""
+ def __init__(self, proj_id: str = "", branch_list: list = None):
+ self.id = proj_id
+ self.branch_list = branch_list
+ majors_temp: dict[str, Major] = {}
+ branch_re = re.compile(r"^(\d+)\.(\d+)$")
+ if proj_id == "qt/qt5":
+ pass
+ for branch in self.branch_list:
+ matches = branch_re.findall(branch)
+ if matches:
+ match = matches.pop()
+ if match[0] not in majors_temp:
+ majors_temp[match[0]] = Major(int(match[0]), [int(match[1])])
+ else:
+ majors_temp[match[0]].append(int(match[1]))
+ self.majors: list[Major] = list(majors_temp.values())
+
+ def get_latest_branches(self):
+ return [b.latest_name for b in self.majors]
+
+
+parser = argparse.ArgumentParser(description=usage_text,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+parser.add_argument('--simulate', dest='sim', action='store_true',
+ help='Perform a dry run and print proposed actions.')
+args = parser.parse_args()
+
+print("Starting project branch scan...\n")
+
+gerrit = Gerrit()
+projects: dict[str, Project] = {}
+
+# Query qt/qt* projects
+# Response schema: https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#list-projects
+r = gerrit.get("projects/?r=^qt/qt.*&state=ACTIVE")
+projects_list = json.loads(trim_response(r.text)).keys() # Get just the project names from response
+
+# Get project branches
+for project in projects_list:
+ # Response schema https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#list-branches
+ # project id must be URL quoted to retrieve project correctly.
+ r = gerrit.get(f"projects/{urllib.parse.quote(project, safe='')}/branches")
+
+ branch_list = []
+ for branch in json.loads(trim_response(r.text)):
+ matches = re.findall(r"heads/(\d+\.\d+)$", branch["ref"])
+ if matches:
+ branch_list.append(matches.pop())
+
+ if branch_list:
+ # Populate Project objects
+ projects[project] = Project(project, branch_list)
+ print(f"Got project {project} with highest branches"
+ f" {', '.join(projects[project].get_latest_branches())}")
+ else:
+ print(f"Project {project} has no applicable branches.")
+
+print("\nFinished project branch scan...\n")
+
+print("Starting open change discovery...\n")
+
+# Keep track of actions taken
+added_comment = 0
+has_comment = 0
+
+for project in projects.values():
+ for major in project.majors:
+ print(f'Pulling changes for {project.id} on major version "{major.major_ver}"')
+ # Get the list of open changes that are missing the latest stable
+ # branch in the pick-to footer
+ # Response Schema https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
+ r = gerrit.get('changes/?q=(is:open)+'
+ f'and+project:{project.id}+'
+ 'and+branch:dev+'
+ f'and+message:"{major.previous_name}"'
+ '&o=CURRENT_REVISION&o=CURRENT_COMMIT')
+ changes = json.loads(trim_response(r.text))
+
+ for change in changes:
+ current_revision = change["current_revision"]
+ line_no = 0
+ pick_targets = []
+ message_body = change["revisions"][current_revision]["commit"]["message"]
+ for i, line in enumerate(message_body.split("\n"), 7):
+ if line.startswith("Pick-to:"):
+ # Compensate line_no for hidden commit message headers since
+ # they aren't considered when posting a review
+ line_no = i
+ # Append because sometimes people write multiple lines of pick targets
+ # instead of a single line with multiple targets
+ pick_targets += line.removeprefix("Pick-to: ").split()
+
+ # Our gerrit query can't perform regexes on the commit message,
+ # so check to make sure that the old branch was actually in the
+ # pick-to targets, and the newer branch was not.
+ if major.latest_name in pick_targets:
+ continue # Nothing to do, has the latest branch already.
+ if major.previous_name not in pick_targets:
+ continue # Must have seen the older target somewhere else in the commit message.
+
+ review_comment = f"Omission of {major.latest_name} is probably incorrect"
+ # Response schema: https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-comments
+ r = gerrit.get(f'changes/{change["id"]}/revisions/current/comments')
+ messages = json.loads(trim_response(r.text))
+ skip = False
+ if any(m["author"]["username"] == gerrit.gerrit_user and m["message"] == review_comment
+ for m in messages.get("/COMMIT_MSG", [])):
+ # The gerrit user already posted a message to this change. Skip it.
+ print(f"Skipping {change['id']}."
+ f" Already posted a warning on the current patchset for {major.latest_name}.")
+ has_comment += 1
+ continue
+ print(f"Post message to {change['id']}."
+ f" Has {major.previous_name}, missing {major.latest_name}")
+ added_comment += 1
+ data = {
+ "message": f"This change targets {major.previous_name} for cherry-picking,"
+ f" but omits the latest stable branch {major.latest_name}."
+ f" Please either add {major.latest_name} or override"
+ " this sanity message.",
+ "labels": {
+ "Sanity-Review": "-1"
+ },
+ "comments": {
+ "/COMMIT_MSG": [{
+ "line": line_no,
+ "message": f"Omission of {major.latest_name} is probably incorrect"
+ }]
+ },
+ "add_to_attention_set": [{
+ "user": change["revisions"][current_revision]["commit"]["author"]["email"],
+ "reason": "Sanity warning: Pick-to targets missing"
+ }]
+ }
+ if args.sim:
+ print(f"SIM: Post comment to {change['id']}")
+ else:
+ # Set Review schema: https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#set-review
+ r = gerrit.post(f"changes/{change['id']}/revisions/current/review", data=data)
+ print(f"Posted comment to {change['id']}. Response -> [{r.status_code}]: {r.text}")
+
+
+print(f"\nPosted comments to {added_comment} changes")
+print(f"Found existing comment on {has_comment} changes")