summaryrefslogtreecommitdiff
path: root/scripts/qt/warn_cherry-pick_branches.py
blob: 3ae1a5ab652065dafdcf5703879483594a5bddd7 (plain)
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
#!/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")