Introduce issue migration script
This commit introduces the issue migration script for splitting x-pack into x-pack-elasticsearch, x-pack-kibana, and x-pack-logstash. Relates elastic/elasticsearch#4935 Original commit: elastic/x-pack-elasticsearch@33a00e5d06
This commit is contained in:
parent
e51b850d75
commit
be3b5f49d0
|
@ -0,0 +1,194 @@
|
|||
import argparse
|
||||
import calendar
|
||||
import json
|
||||
import re
|
||||
import requests
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
def throttle(response):
|
||||
if 'X-RateLimit-Remaining' in response.headers:
|
||||
rate_limit_remaining = int(response.headers['X-RateLimit-Remaining'])
|
||||
if rate_limit_remaining < 16:
|
||||
rate_limit_reset = int(response.headers['X-RateLimit-Reset'])
|
||||
delay = rate_limit_reset - calendar.timegm(time.gmtime())
|
||||
print('rate limit remaining: {}, rate limit reset: {}, throttling for {} seconds'.format(rate_limit_remaining, rate_limit_reset, delay))
|
||||
if delay >= 0:
|
||||
time.sleep(delay)
|
||||
|
||||
return response
|
||||
|
||||
def authorization_token(token):
|
||||
return {'Authorization':'token {}'.format(token)}
|
||||
|
||||
def next_url(response):
|
||||
"""Return the next URL following the Link header, otherwise None."""
|
||||
nu = None
|
||||
if 'Link' in response.headers:
|
||||
links = response.headers['Link'].split(',')
|
||||
for link in links:
|
||||
if 'rel="next"' in link:
|
||||
nu = link.split(';')[0][1:-1]
|
||||
break
|
||||
|
||||
return nu
|
||||
|
||||
def get(url, token):
|
||||
"""Return the response for the specified URL."""
|
||||
headers = authorization_token(token)
|
||||
return throttle(requests.get(url, headers=headers))
|
||||
|
||||
def get_all(url, token):
|
||||
"""Returns all pages starting at the specified URL."""
|
||||
items = []
|
||||
while url is not None:
|
||||
response = get(url, token)
|
||||
json = response.json()
|
||||
if json:
|
||||
items.extend(response.json())
|
||||
url = next_url(response)
|
||||
else:
|
||||
url = None
|
||||
|
||||
return items
|
||||
|
||||
def issue_comments(source_owner, source_repo, issue, token):
|
||||
"""Return all issue comments."""
|
||||
url = 'https://api.github.com/repos/{}/{}/issues/{}/comments'.format(source_owner, source_repo, issue)
|
||||
return get_all(url, token)
|
||||
|
||||
assignees_cache = {}
|
||||
|
||||
def repo_assignees(assignee, owner, repo, token):
|
||||
"""Returns True if the assignee is valid for the specified owner/repo, otherwise False."""
|
||||
if assignee in assignees_cache:
|
||||
return assignees_cache[assignee]
|
||||
url = 'https://api.github.com/repos/{}/{}/assignees/{}'.format(owner, repo, assignee)
|
||||
response = get(url, token)
|
||||
assignees_cache[assignee] = response.status_code == 204
|
||||
return assignees_cache[assignee]
|
||||
|
||||
def rewrite_issue_links(text, source_owner, source_repo):
|
||||
return re.sub(r"(\s+)(#\d+)", "\\1{}/{}\\2".format(source_owner, source_repo), text)
|
||||
|
||||
def rewrite_commits(text, source_owner, source_repo, temp):
|
||||
commits = []
|
||||
for match in re.finditer(r"(?<!@)[a-f0-9]{7,40}", text):
|
||||
if subprocess.call(['git', '-C', temp, 'cat-file', 'commit', match.group(0)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0:
|
||||
commits.append(match.group(0))
|
||||
|
||||
for commit in commits:
|
||||
text = re.sub(r"(?<!@)({})".format(commit), "{}/{}@{}".format(source_owner, source_repo, commit), text)
|
||||
|
||||
return text
|
||||
|
||||
def copy_issue(issue, label_names, source_owner, source_repo, destination_owner, destination_repo, token, temp):
|
||||
"""Posts an issue to the specified owner/repo with the specified labels."""
|
||||
url = 'https://api.github.com/repos/{}/{}/issues'.format(destination_owner, destination_repo)
|
||||
headers = authorization_token(token)
|
||||
issue_number = issue['number']
|
||||
title = issue['title']
|
||||
issue_body = rewrite_commits(rewrite_issue_links(issue['body'], source_owner, source_repo), source_owner, source_repo, temp)
|
||||
body = '*Original comment by @{}:*'.format(issue['user']['login']) + '\n\n' + issue_body + '\n\n' + 'Supercedes {}/{}#{}'.format(source_owner, source_repo, issue_number)
|
||||
labels = [label for label in label_names]
|
||||
assignees = [assignee['login'] for assignee in issue['assignees'] if repo_assignees(assignee['login'], destination_owner, destination_repo, token)]
|
||||
payload = { 'title':title, 'body':body, 'labels':labels, 'assignees':assignees }
|
||||
return throttle(requests.post(url, headers=headers, data=json.dumps(payload)))
|
||||
|
||||
def close_issue(issue_number, owner, repo, token):
|
||||
url = 'https://api.github.com/repos/{}/{}/issues/{}'.format(owner, repo, issue_number)
|
||||
headers = authorization_token(token)
|
||||
payload = { 'state':'closed' }
|
||||
return throttle(requests.post(url, headers=headers, data=json.dumps(payload)))
|
||||
|
||||
def copy_comment(comment, source_owner, source_repo, destination_owner, destination_repo, issue_number, token, temp):
|
||||
"""Copies a comment to the specified issue in the owner/repo."""
|
||||
comment_body = rewrite_commits(rewrite_issue_links(comment['body'], source_owner, source_repo), source_owner, source_repo, temp)
|
||||
body = '*Original comment by @{}:*'.format(comment['user']['login']) + '\n\n' + comment_body
|
||||
return post_comment(body, destination_owner, destination_repo, issue_number, token)
|
||||
|
||||
def post_comment(body, destination_owner, destination_repo, issue_number, token):
|
||||
"""Posts a comment to the specified issue in the owner/repo."""
|
||||
url = 'https://api.github.com/repos/{}/{}/issues/{}/comments'.format(destination_owner, destination_repo, issue_number)
|
||||
headers = authorization_token(token)
|
||||
payload = { 'body':body }
|
||||
return throttle(requests.post(url, headers=headers, data=json.dumps(payload)))
|
||||
|
||||
def post_label(name, color, destination_owner, destination_repo, token):
|
||||
url = 'https://api.github.com/repos/{}/{}/labels/{}'.format(destination_owner, destination_repo, name)
|
||||
headers = authorization_token(token)
|
||||
payload = { 'name':name, 'color':color }
|
||||
response = throttle(requests.post(url, headers=headers, data=json.dumps(payload)))
|
||||
if response.status_code != 200:
|
||||
raise('updating label {} failed'.format(name))
|
||||
|
||||
def merge_labels(existing_labels, new_labels):
|
||||
existing_labels.update({label['name']:label['color'] for label in new_labels})
|
||||
|
||||
def update_labels(labels, destination_owner, destination_repo, token):
|
||||
for name, color in labels.items():
|
||||
post_label(name, color, destination_owner, destination_repo, token)
|
||||
|
||||
def main(source_owner, source_repo, destination_owner, kibana_destination_repo, logstash_destination_repo, elasticsearch_destination_repo, token):
|
||||
temp = tempfile.mkdtemp()
|
||||
subprocess.call(['git', 'clone', 'https://{}@github.com/{}/{}'.format(token, source_owner, source_repo), temp], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
kibana_labels = {}
|
||||
logstash_labels = {}
|
||||
elasticsearch_labels = {}
|
||||
url = 'https://api.github.com/repos/{}/{}/issues?direction=asc'.format(source_owner, source_repo)
|
||||
while url is not None:
|
||||
issues = get(url, token)
|
||||
for issue in [i for i in issues.json() if 'pull_request' not in i]:
|
||||
# issue_number represents the issue number in the source repo
|
||||
issue_number = issue['number']
|
||||
print('processing issue: {}'.format(issue_number))
|
||||
labels = issue['labels']
|
||||
label_names = {label['name'] for label in labels}
|
||||
if ':UI' in label_names or ':reporting' in label_names:
|
||||
merge_labels(kibana_labels, labels)
|
||||
destination_repo = kibana_destination_repo
|
||||
elif ':logstash' in label_names:
|
||||
merge_labels(logstash_labels, labels)
|
||||
destination_repo = logstash_destination_repo
|
||||
else:
|
||||
merge_labels(elasticsearch_labels, labels)
|
||||
destination_repo = elasticsearch_destination_repo
|
||||
new_issue = copy_issue(issue, label_names, source_owner, source_repo, destination_owner, destination_repo, token, temp)
|
||||
# new_issue_number represents the issue number in the destination repo√
|
||||
new_issue_number = new_issue.json()['number']
|
||||
print('issue {} copied to new issue {} in {}/{}'.format(issue_number, new_issue_number, destination_owner, destination_repo))
|
||||
# post each comment from the source issue to the destination issue
|
||||
comments = issue_comments(source_owner, source_repo, issue_number, token)
|
||||
for comment in comments:
|
||||
copy_comment(comment, source_owner, source_repo, destination_owner, destination_repo, new_issue_number, token, temp)
|
||||
|
||||
# comment on the original issue referring to the new issue
|
||||
post_comment('Superceded by {}/{}#{}'.format(destination_owner, destination_repo, new_issue_number), source_owner, source_repo, issue_number, token)
|
||||
close_issue(issue_number, source_owner, source_repo, token)
|
||||
print('issue {} closed'.format(issue_number))
|
||||
|
||||
# proceed to the next page of issues
|
||||
url = next_url(issues)
|
||||
|
||||
# update labels
|
||||
update_labels(kibana_labels, destination_owner, kibana_destination_repo, token)
|
||||
update_labels(logstash_labels, destination_owner, logstash_destination_repo, token)
|
||||
update_labels(elasticsearch_labels, destination_owner, elasticsearch_destination_repo, token)
|
||||
|
||||
finally:
|
||||
shutil.rmtree(temp)
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description='Migrate issues')
|
||||
parser.add_argument('--source_owner', required=True)
|
||||
parser.add_argument('--source_repo', required=True)
|
||||
parser.add_argument('--destination_owner', required=True)
|
||||
parser.add_argument('--kibana_destination_repo', required=True)
|
||||
parser.add_argument('--logstash_destination_repo', required=True)
|
||||
parser.add_argument('--elasticsearch_destination_repo', required=True)
|
||||
parser.add_argument('--token', required=True)
|
||||
args = parser.parse_args()
|
||||
main(args.source_owner, args.source_repo, args.destination_owner, args.kibana_destination_repo, args.logstash_destination_repo, args.elasticsearch_destination_repo, args.token)
|
Loading…
Reference in New Issue