From 780be38266ae4870cdd6751314cc7934fd5ae293 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 30 Dec 2014 16:12:54 +0000 Subject: [PATCH 1/9] Remove trailing white spaces --- README.md | 2 +- gh-issues-import.py | 166 ++++++++++++++++++++++---------------------- query.py | 2 +- 3 files changed, 85 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index c645944..f7f17ba 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The script will by default look for a file named `config.ini` located in the sam To quickly get started, rename `config.ini.sample` to `config.ini`, and edit the fields to match your login info and repository info. If you want to use a different credentials for the source and target repositories, please see [_Configuration: Enterprise Accounts and Advanced Login Options_](http://www.iqandreas.com/github-issues-import/configuration/#enterprise). Store the config file in the same folder as the `gh-issues-import.py` script, or store it in a different folder, using the `--config ` option to specify which config file to load in. **Warning:** The password is stored in plain-text, so avoid storing the config file in a public repository. To avoid this, you can instead pass the username and/or password as arguments by using the `-u ` and `-p ` flags respectively. If the username or password is not passed in from either of these locations, the user will be prompted for them when the script runs. - + Run the script with the following command to import all open issues into the repository defined in the config: ``` diff --git a/gh-issues-import.py b/gh-issues-import.py index ee354e0..1775e1f 100755 --- a/gh-issues-import.py +++ b/gh-issues-import.py @@ -23,7 +23,7 @@ class state: IMPORTING = "importing" IMPORT_COMPLETE = "import-complete" COMPLETE = "script-complete" - + state.current = state.INITIALIZING http_error_messages = {} @@ -33,32 +33,32 @@ class state: def init_config(): - + config.add_section('login') config.add_section('source') config.add_section('target') config.add_section('format') config.add_section('settings') - + arg_parser = argparse.ArgumentParser(description="Import issues from one GitHub repository into another.") - + config_group = arg_parser.add_mutually_exclusive_group(required=False) config_group.add_argument('--config', help="The location of the config file (either absolute, or relative to the current working directory). Defaults to `config.ini` found in the same folder as this script.") config_group.add_argument('--no-config', dest='no_config', action='store_true', help="No config file will be used, and the default `config.ini` will be ignored. Instead, all settings are either passed as arguments, or (where possible) requested from the user as a prompt.") - + arg_parser.add_argument('-u', '--username', help="The username of the account that will create the new issues. The username will not be stored anywhere if passed in as an argument.") arg_parser.add_argument('-p', '--password', help="The password (in plaintext) of the account that will create the new issues. The password will not be stored anywhere if passed in as an argument.") arg_parser.add_argument('-s', '--source', help="The source repository which the issues should be copied from. Should be in the format `user/repository`.") arg_parser.add_argument('-t', '--target', help="The destination repository which the issues should be copied to. Should be in the format `user/repository`.") - - arg_parser.add_argument('--ignore-comments', dest='ignore_comments', action='store_true', help="Do not import comments in the issue.") + + arg_parser.add_argument('--ignore-comments', dest='ignore_comments', action='store_true', help="Do not import comments in the issue.") arg_parser.add_argument('--ignore-milestone', dest='ignore_milestone', action='store_true', help="Do not import the milestone attached to the issue.") arg_parser.add_argument('--ignore-labels', dest='ignore_labels', action='store_true', help="Do not import labels attached to the issue.") - + arg_parser.add_argument('--issue-template', help="Specify a template file for use with issues.") arg_parser.add_argument('--comment-template', help="Specify a template file for use with comments.") arg_parser.add_argument('--pull-request-template', help="Specify a template file for use with pull requests.") - + include_group = arg_parser.add_mutually_exclusive_group(required=True) include_group.add_argument("--all", dest='import_all', action='store_true', help="Import all issues, regardless of state.") include_group.add_argument("--open", dest='import_open', action='store_true', help="Import only open issues.") @@ -66,7 +66,7 @@ def init_config(): include_group.add_argument("-i", "--issues", type=int, nargs='+', help="The list of issues to import."); args = arg_parser.parse_args() - + def load_config_file(config_file_name): try: config_file = open(config_file_name) @@ -74,7 +74,7 @@ def load_config_file(config_file_name): return True except (FileNotFoundError, IOError): return False - + if args.no_config: print("Ignoring default config file. You may be prompted for some missing settings.") elif args.config: @@ -91,49 +91,49 @@ def load_config_file(config_file_name): print("Default config file not found in '%s'" % config_file_name) print("You may be prompted for some missing settings.") - + if args.username: config.set('login', 'username', args.username) if args.password: config.set('login', 'password', args.password) - + if args.source: config.set('source', 'repository', args.source) if args.target: config.set('target', 'repository', args.target) - + if args.issue_template: config.set('format', 'issue_template', args.issue_template) if args.comment_template: config.set('format', 'comment_template', args.comment_template) if args.pull_request_template: config.set('format', 'pull_request_template', args.pull_request_template) - + config.set('settings', 'import-comments', str(not args.ignore_comments)) config.set('settings', 'import-milestone', str(not args.ignore_milestone)) config.set('settings', 'import-labels', str(not args.ignore_labels)) - + config.set('settings', 'import-open-issues', str(args.import_all or args.import_open)); config.set('settings', 'import-closed-issues', str(args.import_all or args.import_closed)); - - + + # Make sure no required config values are missing if not config.has_option('source', 'repository') : sys.exit("ERROR: There is no source repository specified either in the config file, or as an argument.") if not config.has_option('target', 'repository') : sys.exit("ERROR: There is no target repository specified either in the config file, or as an argument.") - - + + def get_server_for(which): # Default to 'github.com' if no server is specified if (not config.has_option(which, 'server')): config.set(which, 'server', "github.com") - + # if SOURCE server is not github.com, then assume ENTERPRISE github (yourdomain.com/api/v3...) if (config.get(which, 'server') == "github.com") : api_url = "https://api.github.com" else: api_url = "https://%s/api/v3" % config.get(which, 'server') - + config.set(which, 'url', "%s/repos/%s" % (api_url, config.get(which, 'repository'))) - + get_server_for('source') get_server_for('target') - - + + # Prompt for username/password if none is provided in either the config or an argument def get_credentials_for(which): if not config.has_option(which, 'username'): @@ -144,7 +144,7 @@ def get_credentials_for(which): else: query_str = "Enter your username for '%s' at '%s': " % (config.get(which, 'repository'), config.get(which, 'server')) config.set(which, 'username', query.username(query_str)) - + if not config.has_option(which, 'password'): if config.has_option('login', 'password'): config.set(which, 'password', config.get('login', 'password')) @@ -153,10 +153,10 @@ def get_credentials_for(which): else: query_str = "Enter your password for '%s' at '%s': " % (config.get(which, 'repository'), config.get(which, 'server')) config.set(which, 'password', query.password(query_str)) - + get_credentials_for('source') get_credentials_for('target') - + # Everything is here! Continue on our merry way... return args.issues or [] @@ -165,7 +165,7 @@ def format_date(datestring): date = datetime.datetime.strptime(datestring, "%Y-%m-%dT%H:%M:%SZ") date_format = config.get('format', 'date', fallback='%A %b %d, %Y at %H:%M GMT', raw=True); return date.strftime(date_format) - + def format_from_template(template_filename, template_data): from string import Template template_file = open(template_filename, 'r') @@ -191,26 +191,26 @@ def send_request(which, url, post_data=None): if post_data is not None: post_data = json.dumps(post_data).encode("utf-8") - + full_url = "%s/%s" % (config.get(which, 'url'), url) req = urllib.request.Request(full_url, post_data) - + username = config.get(which, 'username') password = config.get(which, 'password') req.add_header("Authorization", b"Basic " + base64.urlsafe_b64encode(username.encode("utf-8") + b":" + password.encode("utf-8"))) - + req.add_header("Content-Type", "application/json") req.add_header("Accept", "application/json") req.add_header("User-Agent", "IQAndreas/github-issues-import") - + try: response = urllib.request.urlopen(req) json_data = response.read() except urllib.error.HTTPError as error: - + error_details = error.read(); error_details = json.loads(error_details.decode("utf-8")) - + if error.code in http_error_messages: sys.exit(http_error_messages[error.code]) else: @@ -218,7 +218,7 @@ def send_request(which, url, post_data=None): if 'message' in error_details: error_message += "\nDETAILS: " + error_details['message'] sys.exit(error_message) - + return json.loads(json_data.decode("utf-8")) def get_milestones(which): @@ -226,7 +226,7 @@ def get_milestones(which): def get_labels(which): return send_request(which, "labels") - + def get_issue_by_id(which, issue_id): return send_request(which, "issues/%d" % issue_id) @@ -235,7 +235,7 @@ def get_issues_by_id(which, issue_ids): issues = [] for issue_id in issue_ids: issues.append(get_issue_by_id(which, int(issue_id))) - + return issues # Allowed values for state are 'open' and 'closed' @@ -263,7 +263,7 @@ def import_milestone(source): "description": source['description'], "due_on": source['due_on'] } - + result_milestone = send_request('target', "milestones", source) print("Successfully created milestone '%s'" % result_milestone['title']) return result_milestone @@ -273,7 +273,7 @@ def import_label(source): "name": source['name'], "color": source['color'] } - + result_label = send_request('target', "labels", source) print("Successfully created label '%s'" % result_label['name']) return result_label @@ -281,7 +281,7 @@ def import_label(source): def import_comments(comments, issue_number): result_comments = [] for comment in comments: - + template_data = {} template_data['user_name'] = comment['user']['login'] template_data['user_url'] = comment['user']['html_url'] @@ -289,49 +289,49 @@ def import_comments(comments, issue_number): template_data['date'] = format_date(comment['created_at']) template_data['url'] = comment['html_url'] template_data['body'] = comment['body'] - + comment['body'] = format_comment(template_data) result_comment = send_request('target', "issues/%s/comments" % issue_number, comment) result_comments.append(result_comment) - + return result_comments # Will only import milestones and issues that are in use by the imported issues, and do not exist in the target repository def import_issues(issues): state.current = state.GENERATING - + known_milestones = get_milestones('target') def get_milestone_by_title(title): for milestone in known_milestones: if milestone['title'] == title : return milestone return None - + known_labels = get_labels('target') def get_label_by_name(name): for label in known_labels: if label['name'] == name : return label return None - + new_issues = [] num_new_comments = 0 new_milestones = [] new_labels = [] - + for issue in issues: - + new_issue = {} new_issue['title'] = issue['title'] - + # Temporary fix for marking closed issues if issue['closed_at']: new_issue['title'] = "[CLOSED] " + new_issue['title'] - + if config.getboolean('settings', 'import-comments') and 'comments' in issue and issue['comments'] != 0: num_new_comments += int(issue['comments']) new_issue['comments'] = get_comments_on_issue('source', issue) - + if config.getboolean('settings', 'import-milestone') and 'milestone' in issue and issue['milestone'] is not None: # Since the milestones' ids are going to differ, we will compare them by title instead found_milestone = get_milestone_by_title(issue['milestone']['title']) @@ -342,7 +342,7 @@ def get_label_by_name(name): new_issue['milestone_object'] = new_milestone known_milestones.append(new_milestone) # Allow it to be found next time new_milestones.append(new_milestone) # Put it in a queue to add it later - + if config.getboolean('settings', 'import-labels') and 'labels' in issue and issue['labels'] is not None: new_issue['label_objects'] = [] for issue_label in issue['labels']: @@ -353,7 +353,7 @@ def get_label_by_name(name): new_issue['label_objects'].append(issue_label) known_labels.append(issue_label) # Allow it to be found next time new_labels.append(issue_label) # Put it in a queue to add it later - + template_data = {} template_data['user_name'] = issue['user']['login'] template_data['user_url'] = issue['user']['html_url'] @@ -361,89 +361,89 @@ def get_label_by_name(name): template_data['date'] = format_date(issue['created_at']) template_data['url'] = issue['html_url'] template_data['body'] = issue['body'] - + if "pull_request" in issue and issue['pull_request']['html_url'] is not None: new_issue['body'] = format_pull_request(template_data) else: new_issue['body'] = format_issue(template_data) - + new_issues.append(new_issue) - + state.current = state.IMPORT_CONFIRMATION - + print("You are about to add to '" + config.get('target', 'repository') + "':") - print(" *", len(new_issues), "new issues") - print(" *", num_new_comments, "new comments") - print(" *", len(new_milestones), "new milestones") - print(" *", len(new_labels), "new labels") + print(" *", len(new_issues), "new issues") + print(" *", num_new_comments, "new comments") + print(" *", len(new_milestones), "new milestones") + print(" *", len(new_labels), "new labels") if not query.yes_no("Are you sure you wish to continue?"): sys.exit() - + state.current = state.IMPORTING - + for milestone in new_milestones: result_milestone = import_milestone(milestone) milestone['number'] = result_milestone['number'] milestone['url'] = result_milestone['url'] - + for label in new_labels: result_label = import_label(label) - + result_issues = [] for issue in new_issues: - + if 'milestone_object' in issue: issue['milestone'] = issue['milestone_object']['number'] del issue['milestone_object'] - + if 'label_objects' in issue: issue_labels = [] for label in issue['label_objects']: issue_labels.append(label['name']) issue['labels'] = issue_labels del issue['label_objects'] - + result_issue = send_request('target', "issues", issue) print("Successfully created issue '%s'" % result_issue['title']) - + if 'comments' in issue: - result_comments = import_comments(issue['comments'], result_issue['number']) + result_comments = import_comments(issue['comments'], result_issue['number']) print(" > Successfully added", len(result_comments), "comments.") - + result_issues.append(result_issue) - + state.current = state.IMPORT_COMPLETE - + return result_issues if __name__ == '__main__': - + state.current = state.LOADING_CONFIG - - issue_ids = init_config() + + issue_ids = init_config() issues = [] - + state.current = state.FETCHING_ISSUES - + # Argparser will prevent us from getting both issue ids and specifying issue state, so no duplicates will be added if (len(issue_ids) > 0): issues += get_issues_by_id('source', issue_ids) - + if config.getboolean('settings', 'import-open-issues'): issues += get_issues_by_state('source', 'open') - + if config.getboolean('settings', 'import-closed-issues'): issues += get_issues_by_state('source', 'closed') - + # Sort issues based on their original `id` field # Confusing, but taken from http://stackoverflow.com/a/2878123/617937 issues.sort(key=lambda x:x['number']) - + # Further states defined within the function # Finally, add these issues to the target repository import_issues(issues) - + state.current = state.COMPLETE diff --git a/query.py b/query.py index 0a465b5..f99146e 100644 --- a/query.py +++ b/query.py @@ -15,7 +15,7 @@ def password(question): def yes_no(question, default=True): choices = {"yes":True, "y":True, "ye":True, "no":False, "n":False } - + if default == None: prompt = " [y/n] " elif default == True: From 8e63e700db92993c17e0c38246bde1a39b59b774 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 30 Dec 2014 16:58:00 -0300 Subject: [PATCH 2/9] Add --dry-run mode to see what it will be done Only the POST requests are omitted, the rest of the work is performed completely. --- gh-issues-import.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/gh-issues-import.py b/gh-issues-import.py index 1775e1f..0d84086 100755 --- a/gh-issues-import.py +++ b/gh-issues-import.py @@ -51,6 +51,7 @@ def init_config(): arg_parser.add_argument('-s', '--source', help="The source repository which the issues should be copied from. Should be in the format `user/repository`.") arg_parser.add_argument('-t', '--target', help="The destination repository which the issues should be copied to. Should be in the format `user/repository`.") + arg_parser.add_argument('-n', '--dry-run', dest='dry_run', action='store_true', help="Don't actually create anything, just print what's going to be done.") arg_parser.add_argument('--ignore-comments', dest='ignore_comments', action='store_true', help="Do not import comments in the issue.") arg_parser.add_argument('--ignore-milestone', dest='ignore_milestone', action='store_true', help="Do not import the milestone attached to the issue.") arg_parser.add_argument('--ignore-labels', dest='ignore_labels', action='store_true', help="Do not import labels attached to the issue.") @@ -102,6 +103,7 @@ def load_config_file(config_file_name): if args.comment_template: config.set('format', 'comment_template', args.comment_template) if args.pull_request_template: config.set('format', 'pull_request_template', args.pull_request_template) + config.set('settings', 'dry-run', str(args.dry_run)) config.set('settings', 'import-comments', str(not args.ignore_comments)) config.set('settings', 'import-milestone', str(not args.ignore_milestone)) config.set('settings', 'import-labels', str(not args.ignore_labels)) @@ -189,11 +191,13 @@ def format_comment(template_data): def send_request(which, url, post_data=None): - if post_data is not None: - post_data = json.dumps(post_data).encode("utf-8") + if post_data is None: + json_data = None + else: + json_data = json.dumps(post_data).encode("utf-8") full_url = "%s/%s" % (config.get(which, 'url'), url) - req = urllib.request.Request(full_url, post_data) + req = urllib.request.Request(full_url, json_data) username = config.get(which, 'username') password = config.get(which, 'password') @@ -203,6 +207,11 @@ def send_request(which, url, post_data=None): req.add_header("Accept", "application/json") req.add_header("User-Agent", "IQAndreas/github-issues-import") + if post_data is not None and config.getboolean('settings', 'dry-run'): + post_data['number'] = post_data.get('number', 0) + print("dry-run:", verb.upper(), full_url) + return post_data + try: response = urllib.request.urlopen(req) json_data = response.read() From b99a5fdd63b6e0239a1b0c426c236fb08e26b591 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 30 Dec 2014 20:00:13 +0000 Subject: [PATCH 3/9] Don't ask questions when not in a tty This allows the program to be called via a pipe, for example to get a list of issues to copy from another command and then invoking the import via | xargs. --- query.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/query.py b/query.py index f99146e..485ed1a 100644 --- a/query.py +++ b/query.py @@ -13,6 +13,10 @@ def password(question): # Taken from http://code.activestate.com/recipes/577058-query-yesno/ # with some personal modifications def yes_no(question, default=True): + # Assume the default if we can't ask (not in a tty) + if not sys.stdin.isatty(): + return default + choices = {"yes":True, "y":True, "ye":True, "no":False, "n":False } From 9c17713118aff97fef1fb1caec27156f5e97de5c Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 30 Dec 2014 20:02:27 +0000 Subject: [PATCH 4/9] Add OAuth token authentication support --- config.ini.sample | 5 +++-- gh-issues-import.py | 18 ++++++++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/config.ini.sample b/config.ini.sample index fd9f34b..c8b9063 100644 --- a/config.ini.sample +++ b/config.ini.sample @@ -2,8 +2,9 @@ # For a full list of options, see [login] -username = OctoDog -password = plaintext_pa$$w0rd +#username = OctoDog +#password = plaintext_pa$$w0rd +oauthtoken = somegithubprovidedoauthtoken [source] repository = OctoCat/Hello-World diff --git a/gh-issues-import.py b/gh-issues-import.py index 0d84086..114fe90 100755 --- a/gh-issues-import.py +++ b/gh-issues-import.py @@ -27,7 +27,7 @@ class state: state.current = state.INITIALIZING http_error_messages = {} -http_error_messages[401] = "ERROR: There was a problem during authentication.\nDouble check that your username and password are correct, and that you have permission to read from or write to the specified repositories." +http_error_messages[401] = "ERROR: There was a problem during authentication.\nDouble check that your username and password or oauthtoken are correct, and that you have permission to read from or write to the specified repositories." http_error_messages[403] = http_error_messages[401]; # Basically the same problem. GitHub returns 403 instead to prevent abuse. http_error_messages[404] = "ERROR: Unable to find the specified repository.\nDouble check the spelling for the source and target repositories. If either repository is private, make sure the specified user is allowed access to it." @@ -48,6 +48,7 @@ def init_config(): arg_parser.add_argument('-u', '--username', help="The username of the account that will create the new issues. The username will not be stored anywhere if passed in as an argument.") arg_parser.add_argument('-p', '--password', help="The password (in plaintext) of the account that will create the new issues. The password will not be stored anywhere if passed in as an argument.") + arg_parser.add_argument('-o', '--oauthtoken', help="The GitHub OAuth token to use to authenticate and create the new issues. It will not be stored anywhere if passed in as an argument.") arg_parser.add_argument('-s', '--source', help="The source repository which the issues should be copied from. Should be in the format `user/repository`.") arg_parser.add_argument('-t', '--target', help="The destination repository which the issues should be copied to. Should be in the format `user/repository`.") @@ -95,6 +96,7 @@ def load_config_file(config_file_name): if args.username: config.set('login', 'username', args.username) if args.password: config.set('login', 'password', args.password) + if args.oauthtoken: config.set('login', 'oauthtoken', args.oauthtoken) if args.source: config.set('source', 'repository', args.source) if args.target: config.set('target', 'repository', args.target) @@ -138,6 +140,11 @@ def get_server_for(which): # Prompt for username/password if none is provided in either the config or an argument def get_credentials_for(which): + if config.has_option(which, 'oauthtoken'): + return + if config.has_option('login', 'oauthtoken'): + config.set(which, 'oauthtoken', config.get('login', 'oauthtoken')) + return if not config.has_option(which, 'username'): if config.has_option('login', 'username'): config.set(which, 'username', config.get('login', 'username')) @@ -199,9 +206,12 @@ def send_request(which, url, post_data=None): full_url = "%s/%s" % (config.get(which, 'url'), url) req = urllib.request.Request(full_url, json_data) - username = config.get(which, 'username') - password = config.get(which, 'password') - req.add_header("Authorization", b"Basic " + base64.urlsafe_b64encode(username.encode("utf-8") + b":" + password.encode("utf-8"))) + if config.has_option(which, 'oauthtoken'): + req.add_header("Authorization", b"bearer " + config.get(which, 'oauthtoken').encode('utf-8')) + if config.has_option(which, 'username'): + username = config.get(which, 'username') + password = config.get(which, 'password') + req.add_header("Authorization", b"Basic " + base64.urlsafe_b64encode(username.encode("utf-8") + b":" + password.encode("utf-8"))) req.add_header("Content-Type", "application/json") req.add_header("Accept", "application/json") From a815a6903f9c33a1a525e4d3ae16a71dfe9319c3 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 31 Dec 2014 15:24:21 +0000 Subject: [PATCH 5/9] Remove unused data variable --- gh-issues-import.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/gh-issues-import.py b/gh-issues-import.py index 114fe90..65879fb 100755 --- a/gh-issues-import.py +++ b/gh-issues-import.py @@ -276,23 +276,11 @@ def get_comments_on_issue(which, issue): return [] def import_milestone(source): - data = { - "title": source['title'], - "state": "open", - "description": source['description'], - "due_on": source['due_on'] - } - result_milestone = send_request('target', "milestones", source) print("Successfully created milestone '%s'" % result_milestone['title']) return result_milestone def import_label(source): - data = { - "name": source['name'], - "color": source['color'] - } - result_label = send_request('target', "labels", source) print("Successfully created label '%s'" % result_label['name']) return result_label From fac9f1f048d013146279808b590639b1517a71e5 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 31 Dec 2014 12:53:16 -0300 Subject: [PATCH 6/9] Add support for custom verbs to send_request() This will enable doing other operations with the GitHub API, like marking copied issues that were closed as closed. --- gh-issues-import.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/gh-issues-import.py b/gh-issues-import.py index 65879fb..157b21f 100755 --- a/gh-issues-import.py +++ b/gh-issues-import.py @@ -196,7 +196,7 @@ def format_comment(template_data): template = config.get('format', 'comment_template', fallback=default_template) return format_from_template(template, template_data) -def send_request(which, url, post_data=None): +def send_request(which, url, verb='get', post_data=None): if post_data is None: json_data = None @@ -216,8 +216,9 @@ def send_request(which, url, post_data=None): req.add_header("Content-Type", "application/json") req.add_header("Accept", "application/json") req.add_header("User-Agent", "IQAndreas/github-issues-import") + req.get_method = lambda: verb.upper() - if post_data is not None and config.getboolean('settings', 'dry-run'): + if verb.upper() not in ('HEAD', 'GET') and config.getboolean('settings', 'dry-run'): post_data['number'] = post_data.get('number', 0) print("dry-run:", verb.upper(), full_url) return post_data @@ -276,12 +277,12 @@ def get_comments_on_issue(which, issue): return [] def import_milestone(source): - result_milestone = send_request('target', "milestones", source) + result_milestone = send_request('target', "milestones", 'post', source) print("Successfully created milestone '%s'" % result_milestone['title']) return result_milestone def import_label(source): - result_label = send_request('target', "labels", source) + result_label = send_request('target', "labels", 'post', source) print("Successfully created label '%s'" % result_label['name']) return result_label @@ -299,7 +300,7 @@ def import_comments(comments, issue_number): comment['body'] = format_comment(template_data) - result_comment = send_request('target', "issues/%s/comments" % issue_number, comment) + result_comment = send_request('target', "issues/%s/comments" % issue_number, 'post', comment) result_comments.append(result_comment) return result_comments @@ -410,7 +411,7 @@ def get_label_by_name(name): issue['labels'] = issue_labels del issue['label_objects'] - result_issue = send_request('target', "issues", issue) + result_issue = send_request('target', "issues", 'post', issue) print("Successfully created issue '%s'" % result_issue['title']) if 'comments' in issue: From fb4474591e91bcce35aff03c75562d754b9c8ac5 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 31 Dec 2014 12:54:11 -0300 Subject: [PATCH 7/9] Mark closed copied issues as closed too Before a prefix was added to the issue title, now the issue is properly closed. --- gh-issues-import.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/gh-issues-import.py b/gh-issues-import.py index 157b21f..72a758a 100755 --- a/gh-issues-import.py +++ b/gh-issues-import.py @@ -332,10 +332,6 @@ def get_label_by_name(name): new_issue = {} new_issue['title'] = issue['title'] - # Temporary fix for marking closed issues - if issue['closed_at']: - new_issue['title'] = "[CLOSED] " + new_issue['title'] - if config.getboolean('settings', 'import-comments') and 'comments' in issue and issue['comments'] != 0: num_new_comments += int(issue['comments']) new_issue['comments'] = get_comments_on_issue('source', issue) @@ -398,7 +394,7 @@ def get_label_by_name(name): result_label = import_label(label) result_issues = [] - for issue in new_issues: + for i, issue in enumerate(new_issues): if 'milestone_object' in issue: issue['milestone'] = issue['milestone_object']['number'] @@ -412,6 +408,8 @@ def get_label_by_name(name): del issue['label_objects'] result_issue = send_request('target', "issues", 'post', issue) + if issues[i]['state'] == 'closed': + send_request('target', "issues/%(number)s" % result_issue, 'patch', dict(state='closed')) print("Successfully created issue '%s'" % result_issue['title']) if 'comments' in issue: From b1a0bafaeb05b3c054ce2c5b4d80f590f852c094 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 31 Dec 2014 18:22:35 -0300 Subject: [PATCH 8/9] Load all the milestones, not only open ones When importing issues closed issues, we also probably want to import closed milestones too, or at least don't break when issues associated with closed milestones. --- gh-issues-import.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gh-issues-import.py b/gh-issues-import.py index 72a758a..d5ebe2e 100755 --- a/gh-issues-import.py +++ b/gh-issues-import.py @@ -242,7 +242,7 @@ def send_request(which, url, verb='get', post_data=None): return json.loads(json_data.decode("utf-8")) def get_milestones(which): - return send_request(which, "milestones?state=open") + return send_request(which, "milestones?state=all") def get_labels(which): return send_request(which, "labels") From 6482c815f54d5912296dd0da3a9128a5bfd95b24 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 31 Dec 2014 18:25:01 -0300 Subject: [PATCH 9/9] Add support to 'moving' issues Moving issues means closing the original issue and adding a comment saying the issue was moved (through a comment template). --- gh-issues-import.py | 22 ++++++++++++++++++++++ templates/moved_comment.md | 1 + 2 files changed, 23 insertions(+) create mode 100644 templates/moved_comment.md diff --git a/gh-issues-import.py b/gh-issues-import.py index d5ebe2e..4e609bf 100755 --- a/gh-issues-import.py +++ b/gh-issues-import.py @@ -53,12 +53,14 @@ def init_config(): arg_parser.add_argument('-t', '--target', help="The destination repository which the issues should be copied to. Should be in the format `user/repository`.") arg_parser.add_argument('-n', '--dry-run', dest='dry_run', action='store_true', help="Don't actually create anything, just print what's going to be done.") + arg_parser.add_argument('-m', '--move-issues',dest='move_issues', action='store_true', help="Move the issues instead of copying (close the original if it was open and add a comment saying the issue was moved.") arg_parser.add_argument('--ignore-comments', dest='ignore_comments', action='store_true', help="Do not import comments in the issue.") arg_parser.add_argument('--ignore-milestone', dest='ignore_milestone', action='store_true', help="Do not import the milestone attached to the issue.") arg_parser.add_argument('--ignore-labels', dest='ignore_labels', action='store_true', help="Do not import labels attached to the issue.") arg_parser.add_argument('--issue-template', help="Specify a template file for use with issues.") arg_parser.add_argument('--comment-template', help="Specify a template file for use with comments.") + arg_parser.add_argument('--moved-comment-template', help="Specify a template file for use when moving issues.") arg_parser.add_argument('--pull-request-template', help="Specify a template file for use with pull requests.") include_group = arg_parser.add_mutually_exclusive_group(required=True) @@ -104,11 +106,13 @@ def load_config_file(config_file_name): if args.issue_template: config.set('format', 'issue_template', args.issue_template) if args.comment_template: config.set('format', 'comment_template', args.comment_template) if args.pull_request_template: config.set('format', 'pull_request_template', args.pull_request_template) + if args.moved_comment_template: config.set('format', 'moved_comment_template', args.moved_comment_template) config.set('settings', 'dry-run', str(args.dry_run)) config.set('settings', 'import-comments', str(not args.ignore_comments)) config.set('settings', 'import-milestone', str(not args.ignore_milestone)) config.set('settings', 'import-labels', str(not args.ignore_labels)) + config.set('settings', 'move-issues', str(args.move_issues)) config.set('settings', 'import-open-issues', str(args.import_all or args.import_open)); config.set('settings', 'import-closed-issues', str(args.import_all or args.import_closed)); @@ -196,6 +200,11 @@ def format_comment(template_data): template = config.get('format', 'comment_template', fallback=default_template) return format_from_template(template, template_data) +def format_moved_comment(template_data): + default_template = os.path.join(__location__, 'templates', 'moved_comment.md') + template = config.get('format', 'moved_comment_template', fallback=default_template) + return format_from_template(template, template_data) + def send_request(which, url, verb='get', post_data=None): if post_data is None: @@ -220,6 +229,8 @@ def send_request(which, url, verb='get', post_data=None): if verb.upper() not in ('HEAD', 'GET') and config.getboolean('settings', 'dry-run'): post_data['number'] = post_data.get('number', 0) + post_data['url'] = '' + post_data['html_url'] = '' print("dry-run:", verb.upper(), full_url) return post_data @@ -416,6 +427,17 @@ def get_label_by_name(name): result_comments = import_comments(issue['comments'], result_issue['number']) print(" > Successfully added", len(result_comments), "comments.") + if config.getboolean('settings', 'move-issues'): + template_data = {} + template_data['number'] = result_issue['number'] + template_data['url'] = result_issue['html_url'] + template_data['repo'] = config.get('target', 'repository') + comment = dict(body = format_moved_comment(template_data)) + send_request('source', "issues/%(number)s/comments" % issues[i], 'post', comment) + if issues[i]['state'] == 'open': + send_request('source', "issues/%(number)s" % issues[i], 'patch', dict(state='closed')) + print(" > Successfully closed original issue") + result_issues.append(result_issue) state.current = state.IMPORT_COMPLETE diff --git a/templates/moved_comment.md b/templates/moved_comment.md new file mode 100644 index 0000000..88aeda3 --- /dev/null +++ b/templates/moved_comment.md @@ -0,0 +1 @@ +Moved to ${repo}#${number}.