governance/tools/check_review_status.py
Radosław Piliszek b7f45e2266 Remove the tags framework (part 1)
This change enacts the first steps of the TC's decision to remove
the tags framework. [1]

This change does not remove all the parts to avoid breaking
the releases tooling as well as to preserve useful information
as discussed under this change and during one of the TC meetings.
[2]

[1] http://lists.openstack.org/pipermail/openstack-discuss/2021-October/025554.html
[2] https://meetings.opendev.org/meetings/tc/2022/tc.2022-01-20-15.00.log.html

Change-Id: Iab4a136905a9c7a61530ff7576a216d229f717a0
2022-02-03 18:32:27 +00:00

484 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import argparse
import collections
import datetime
import json
import logging
import math
import operator
import prettytable
import requests
from openstack_governance import governance
from openstack_governance import members
LOG = logging.getLogger(__name__)
TC_SIZE = 9
# For formal-vote votes, age in days that a change has to have existed
# before it can be approved
creation_age_threshold = datetime.timedelta(days=7)
def decode_json(raw):
"""Trap JSON decoding failures and provide more detailed errors
Remove ')]}' XSS prefix from data if it is present, then decode it
as JSON and return the results.
:param raw: Response text from API
:type raw: str
"""
# Gerrit's REST API prepends a JSON-breaker to avoid XSS vulnerabilities
if raw.text.startswith(")]}'"):
trimmed = raw.text[4:]
else:
trimmed = raw.text
# Try to decode and bail with much detail if it fails
try:
decoded = json.loads(trimmed)
except Exception:
LOG.error(
'\nrequest returned %s error to query:\n\n %s\n'
'\nwith detail:\n\n %s\n',
raw, raw.url, trimmed)
raise
return decoded
def query_gerrit(offset=0):
"""Query the Gerrit REST API"""
url = 'https://review.opendev.org/changes/'
LOG.debug('fetching %s', url)
raw = requests.get(
url,
params={
'n': '100',
'start': offset,
'q': 'project:openstack/governance is:open',
'o': [
'ALL_REVISIONS',
'REVIEWER_UPDATES',
'DETAILED_ACCOUNTS',
'CURRENT_COMMIT',
'LABELS',
'DETAILED_LABELS',
'MESSAGES',
],
},
headers={'Accept': 'application/json'},
)
return decode_json(raw)
def to_datetime(s, default=None):
"Convert a string to a datetime.datetime instance"
# Ignore the trailing decimal seconds.
if s is None:
return default
s = s.rpartition('.')[0]
return datetime.datetime.strptime(s, '%Y-%m-%d %H:%M:%S')
def find_latest_revision(review):
result = None
result_date = None
for rev in review.get('revisions', {}).values():
rev_date = to_datetime(rev.get('created'))
if not result or rev_date > result_date:
result = rev
result_date = rev_date
return result
def count_votes(review, group='Rollcall-Vote'):
votes = collections.Counter()
votes.update(
vote.get('value')
for vote in review['labels'].get(group, {}).get('all', [])
)
if None in votes:
del votes[None]
return votes
def when_majority(review, required_count):
"Return the date when the vote reached the required count."
n = 0
votes = review['labels'].get('Rollcall-Vote', {}).get('all', [])
now = datetime.datetime.now()
for vote in sorted(votes, key=lambda x: to_datetime(x.get('date'), now)):
if vote.get('value'):
n += 1
if n >= required_count:
d = vote.get('date')
if d:
return to_datetime(d)
def format_votes(votes):
return 'nay:{:2d} / abs:{:2d} / yes:{:2d}'.format(
votes.get(-1, 0), votes.get(0, 0), votes.get(1, 0)
)
def get_votes_by_person(member, review):
for label in ['Code-Review', 'Rollcall-Vote']:
for vote in review['labels'].get(label, {}).get('all', []):
if member['gerritid'] == vote['_account_id']:
yield vote
def has_approved(member, review):
return any(
vote.get('value', 0) == 1
for vote in get_votes_by_person(member, review)
)
def has_rejected(member, review):
return any(
vote.get('value', 0) == -1
for vote in get_votes_by_person(member, review)
)
def has_commented(member, review):
desired_revision = max(
r.get('_number', -1)
for r in review.get('revisions', {}).values()
)
for msg in review.get('messages', []):
if msg.get('_revision_number', -1) != desired_revision:
continue
if msg.get('author', {}).get('_account_id', '') == member['gerritid']:
return True
def all_changes():
offset = 0
while True:
changes = query_gerrit(offset)
yield from changes
if changes and changes[-1].get('_more_changes', False):
offset += 100
else:
break
def get_one_status(change, delegates, tc_members):
topic = change.get('topic', 'unknown topic')
subject = change.get('subject')
owner = change.get('owner', {}).get('name')
url = 'https://review.opendev.org/{}\n'.format(change['_number'])
latest = find_latest_revision(change)
latest_created = to_datetime(latest['created'])
now = datetime.datetime.utcnow()
age = now - latest_created
earliest = ''
code_reviews = count_votes(change, 'Code-Review')
votes = count_votes(change)
workflow = count_votes(change, 'Workflow')
verified = count_votes(change, 'Verified')
can_approve = 'no'
if workflow[-1]:
can_approve = 'WIP'
elif workflow[1]:
can_approve = 'APPROVED'
elif verified[-1]:
can_approve = 'NO, verification failure'
earliest = 'when passing'
elif topic == 'on-hold':
can_approve = 'on hold'
elif topic == 'formal-vote':
# https://governance.openstack.org/tc/reference/charter.html#motions
parts = []
# At least at third rounded up positive votes and more positive than
# negative votes.
necessary_votes = math.ceil(TC_SIZE / 3)
votes_to_approve = votes[1] >= necessary_votes and votes[1] > votes[-1]
reached_necessary_votes = when_majority(change, necessary_votes)
parts.append('last change on {}'.format(
latest_created.isoformat(timespec='minutes')
))
# At least creation_age_threshold days old.
if age < creation_age_threshold:
time_to_approve = False
parts.append("has not been open %d days" %
creation_age_threshold.days)
earliest = str(latest_created + creation_age_threshold)
elif reached_necessary_votes:
time_to_approve = True
else:
time_to_approve = False
earliest = "after {} positive votes".format(necessary_votes)
if votes_to_approve and time_to_approve:
parts.append('YES')
if reached_necessary_votes and not time_to_approve:
parts.append('enough required votes but too soon')
# Even if we can approve it, if there are dissenting votes we
# may want to continue discussion or refine the proposal.
if votes[-1]:
if votes[-1] >= votes[1]:
parts.append('too many dissenting votes')
else:
parts.append('dissenting votes')
if votes[1] < necessary_votes:
parts.append('not enough votes')
elif votes[1] == necessary_votes:
parts.append('minimum favorable votes')
else:
parts.append('sufficient votes')
if not (votes[1] > math.floor(TC_SIZE / 2)):
# Even if we can approve it, if the majority have not
# voted yes we may want to continue discussion or call for
# a final vote.
parts.append('plz whip votes - no majority and things can change')
can_approve = ',\n'.join(parts)
elif topic == 'charter-change':
# https://governance.openstack.org/tc/reference/charter.html#amendment
parts = []
# At least 2/3 positive votes.
necessary_votes = math.ceil(TC_SIZE / 3 * 2)
votes_to_approve = votes[1] > necessary_votes
# Wait least 3 days after reaching majority.
reached_supermajority = when_majority(change, necessary_votes)
if reached_supermajority:
earliest = str(
reached_supermajority.date() + datetime.timedelta(4)
)
since_supermajority = now.date() - reached_supermajority.date()
time_to_approve = since_supermajority > datetime.timedelta(3)
else:
time_to_approve = False
earliest = '4 days after {} positive votes'.format(necessary_votes)
if votes_to_approve and time_to_approve:
parts.append('CAN APPROVE')
if reached_supermajority and not time_to_approve:
parts.append('enough required votes but too soon')
# Even if we can approve it, if there are dissenting votes we
# may want to continue discussion or refine the proposal.
if votes[-1]:
parts.append('dissenting votes')
if votes[1] < necessary_votes:
parts.append('not enough votes')
elif votes[1] == necessary_votes:
parts.append('minimum favorable votes')
else:
parts.append('enough votes')
can_approve = ',\n'.join(parts)
elif topic in (
'goal-proposal',
'code-change',
'documentation-change',
'election-results',
'typo-fix',
):
# https://governance.openstack.org/tc/reference/house-rules.html#community-wide-goal-proposals
# https://governance.openstack.org/tc/reference/house-rules.html#code-changes
# https://governance.openstack.org/tc/reference/house-rules.html#documentation-changes
# https://governance.openstack.org/tc/reference/house-rules.html#election-results
# https://governance.openstack.org/tc/reference/house-rules.html#typo-fixes
earliest = 'Can be voted anytime'
if votes[-1] or code_reviews[-1]:
can_approve = 'dissenting votes'
elif votes[1] < 2:
can_approve = 'not enough reviews'
else:
can_approve = 'CAN APPROVE'
elif topic in delegates.keys():
# https://governance.openstack.org/tc/reference/house-rules.html#delegated-metadata
approver_name = delegates[topic]
can_approve = 'delegated to {}'.format(approver_name)
if has_approved(approver_name, change):
can_approve += '\nYES'
elif has_rejected(approver_name, change):
can_approve += '\nNO - delegate voted against'
elif has_commented(approver_name, change):
can_approve += '\ndelegate has commented'
elif topic in ('project-update', 'new-project'):
# https://governance.openstack.org/tc/reference/house-rules.html#other-project-team-updates
if votes[-1] or code_reviews[-1]:
can_approve = 'dissenting votes'
elif votes[1] < 2:
can_approve = 'not enough reviews'
else:
can_approve = 'CAN APPROVE'
elif topic == 'goal-update':
# https://governance.openstack.org/tc/reference/house-rules.html#goal-updates-from-ptls
# At least 7 days old.
earliest = str(latest_created + creation_age_threshold)
if votes[-1]:
can_approve = 'dissenting votes'
elif age <= datetime.timedelta(7):
can_approve = 'too soon'
else:
can_approve = 'CAN APPROVE'
else:
topic = 'unknown topic'
can_approve = 'unknown topic'
votes = '\n'.join([
'CR:' + format_votes(code_reviews),
' V:' + format_votes(votes),
])
tc_member_votes = {}
for member in tc_members:
name = member['name']
if has_approved(member, change):
tc_member_votes[name] = '+'
elif has_rejected(member, change):
tc_member_votes[name] = '-'
elif has_commented(member, change):
tc_member_votes[name] = 'C'
else:
tc_member_votes[name] = ' '
member_votes = '\n'.join(
'{} : {}'.format(value, name)
for name, value in sorted(tc_member_votes.items())
)
return {
'Topic': topic,
'Subject': subject,
'Summary': '\n'.join([
subject.strip(),
'',
url,
'Submitted by: {}'.format(owner.strip())
]),
'Owner': owner,
'URL': url,
'Age': age.days,
'Date': latest_created.date(),
'Can Approve': can_approve,
'Status': '\n'.join([topic, can_approve,
'{} days old'.format(age.days),
'earliest: {}'.format(earliest)]),
'Earliest': earliest,
'Votes': votes,
'Members': member_votes,
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
'--verbose', '-v',
action='store_const',
dest='log_level',
default=logging.INFO,
const=logging.DEBUG,
)
args = parser.parse_args()
logging.basicConfig(
level=args.log_level,
)
tc_members = members.parse_members_file('./reference/members.yaml')
# NOTE(mnaser): In order to consistently and properly track the votes,
# we need to lookup every users Gerrit account ID based
# on their email.
for member in tc_members:
raw = requests.get(
'https://review.opendev.org/accounts/%s' % member['email'],
headers={'Accept': 'application/json'},
)
member['gerritid'] = decode_json(raw).get('_account_id')
gov = governance.Governance.from_local_repo()
release_team = gov.get_team('Release Management')
delegates = {
'release-management': release_team.ptl['name'],
}
for tag, name in sorted(delegates.items()):
print('Delegating {} tags to {}'.format(tag, name))
status = sorted(
(get_one_status(change, delegates, tc_members)
for change in all_changes()),
key=operator.itemgetter('URL'),
)
columns = (
'Summary',
'Status',
'Votes',
'Members',
)
x = prettytable.PrettyTable(
field_names=columns,
hrules=prettytable.ALL,
)
x.align['Summary'] = 'l'
x.align['Status'] = 'l'
x.align['Votes'] = 'l'
x.align['Members'] = 'l'
for row in status:
x.add_row([row[c] for c in columns])
print(x.get_string())
if __name__ == '__main__':
main()