├── .gitignore ├── LICENSE.md ├── README.md ├── config-enterprise.ini.sample ├── config.ini.sample ├── gh-issues-import.py ├── query.py └── templates ├── comment.md ├── issue.md └── pull_request.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | __pycache__/* 3 | *.py~ 4 | 5 | # This is to prevent you from making the idiotic mistake of committing and uploading your 6 | # config file that includes your password in plaintext into a publicly available repo! 7 | config.ini 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright © 2014 Andreas Renberg <> 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the “Software”), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ### GitHub Issues Import ### 3 | 4 | This Python script allows you to import issues and pull requests from one repository to another; works even for private repositories, and if the two repositories are not related to each other in any way. 5 | 6 | Fork of one of the tools by [Max Korenkov](https://github.com/mkorenkov) separated from its original location at [`mokorenkov/tools`](https://github.com/mkorenkov/tools). 7 | 8 | #### Usage #### 9 | 10 | The script will by default look for a file named `config.ini` located in the same folder as the Python script. For a list of all possible configuration options, see [_Configuration_](http://www.iqandreas.com/github-issues-import/configuration/). 11 | 12 | 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. 13 | 14 | **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. 15 | 16 | Run the script with the following command to import all open issues into the repository defined in the config: 17 | 18 | ``` 19 | $ python3 gh-issues-import.py --open 20 | ``` 21 | 22 | If you want to import all issues (including the closed ones), use `--all` instead of `--open`. Closed issues will still be open in the target repository, but titles will begin with `[CLOSED]`. 23 | 24 | Or to only import specific issues, run the script and include the issue numbers of all issues you wish to import (can be done for one or several issues, and will even include closed issues): 25 | 26 | ``` 27 | $ python3 gh-issues-import.py --issues 25 26 29 28 | ``` 29 | 30 | Some config options can be passed as arguments. For a full list, see [the the _Arguments_ page](http://www.iqandreas.com/github-issues-import/arguments/), or run the script using the `--help` flag. 31 | 32 | #### Result #### 33 | 34 | Every issue imported will create a new issue in the target repository. Remember that the ID of the issue in the new repository will most likely not be the same as the ID of the original issue. Keep this in mind when writing commit messages such as _"Closes #25"_. 35 | 36 | If the issue is a pull request, this will be indicated on the issue, and a link to the code will be provided. However, it will be treated as a new issue in the target repository, and **not** a pull request. Pulling in the suggested code into the repository will need to be done manually. 37 | 38 | Any comments on the issue will be imported, however, the author of all imported comments will be the account specified in the config. Instead, a link and header is provided for each comment indicating who the original author was and the original date and time of the comment. Any subsequent comments added to the issue after it has been imported into the target repository will not be included. 39 | 40 | Labels and milestones attached to the issue will be imported and added to the target repository if they do not already exist there. If the label or milestone with the same name already exists, the issue will point to the existing one, and any difference in the description or other details will be ignored. 41 | 42 | If allowed by GitHub's policies, it may be a good idea to use a "neutral" account to import the issues and issue comments to avoid imported comments from getting mixed up with developer comments (example: [FlixelCommunityBot](https://github.com/FlixelCommunityBot?tab=activity)). 43 | 44 | #### Templates #### 45 | 46 | The script will by default use the [Markdown-formatted](http://github.github.com/github-flavored-markdown/) templates found in the [`templates`]({{site.github_url}}/tree/master/templates/) directory. You can edit those, or point to your own templates from the config file; see [_Custom Templates_](http://www.iqandreas.com/github-issues-import/templates/) for more details. 47 | 48 | #### Examples #### 49 | 50 | [![Example result of an imported pull request](http://www.iqandreas.com/github-issues-import/example-imported-issue.png)](https://github.com/IQAndreas-testprojects/github-issues-import-example/issues/8) 51 | 52 | * [**Example issue (with label)**](https://github.com/IQAndreas-testprojects/github-issues-import-example/issues/8) ([original](https://github.com/IQAndreas/github-issues-import/issues/1)) 53 | * [**Example pull request**](https://github.com/IQAndreas-testprojects/github-issues-import-example/issues/9) ([original](https://github.com/IQAndreas/github-issues-import/issues/2)) 54 | * [**Example issue with comments**](https://github.com/IQAndreas-testprojects/github-issues-import-example/issues/10) ([original](https://github.com/IQAndreas/github-issues-import/issues/3)) 55 | * [**Example issue with milestone**](https://github.com/IQAndreas-testprojects/github-issues-import-example/issues/11) ([original](https://github.com/IQAndreas/github-issues-import/issues/9)) 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /config-enterprise.ini.sample: -------------------------------------------------------------------------------- 1 | # Rename to `config.ini`, and keep it in the same folder as `gh-issues-import.py` 2 | # For a full list of options, see 3 | 4 | [source] 5 | server = github.com 6 | repository = OctoCat/Hello-World 7 | username = octocat@github.com 8 | password = plaintext_pa$$w0rd 9 | 10 | [target] 11 | server = octodog.org 12 | repository = OctoDog/Hello-World 13 | username = admin@octodog.org 14 | password = plaintext_pass\/\/ord 15 | 16 | [format] # These can be adjusted based on your group's region and language. 17 | 18 | # If unsure, just using '%c' will default to the locale’s appropriate date and time representation. 19 | #date = %A %b %d, %Y at %H:%M GMT # Sample: Friday Sep 13, 2013 at 22:58 GMT 20 | 21 | -------------------------------------------------------------------------------- /config.ini.sample: -------------------------------------------------------------------------------- 1 | # Rename to `config.ini`, and keep it in the same folder as `gh-issues-import.py` 2 | # For a full list of options, see 3 | 4 | [login] 5 | username = OctoDog 6 | password = plaintext_pa$$w0rd 7 | 8 | [source] 9 | repository = OctoCat/Hello-World 10 | 11 | [target] 12 | repository = OctoDog/Hello-World 13 | 14 | [format] # These can be adjusted based on your group's region and language. 15 | 16 | # If unsure, just using '%c' will default to the locale’s appropriate date and time representation. 17 | #date = %A %b %d, %Y at %H:%M GMT # Sample: Friday Sep 13, 2013 at 22:58 GMT 18 | 19 | -------------------------------------------------------------------------------- /gh-issues-import.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import urllib.request, urllib.error, urllib.parse 4 | import json 5 | import base64 6 | import sys, os 7 | import datetime 8 | import argparse, configparser 9 | 10 | import query 11 | 12 | __location__ = os.path.realpath(os.path.join(os.getcwd(), os.path.dirname(__file__))) 13 | default_config_file = os.path.join(__location__, 'config.ini') 14 | config = configparser.RawConfigParser() 15 | 16 | class state: 17 | current = "" 18 | INITIALIZING = "script-initializing" 19 | LOADING_CONFIG = "loading-config" 20 | FETCHING_ISSUES = "fetching-issues" 21 | GENERATING = "generating" 22 | IMPORT_CONFIRMATION = "import-confirmation" 23 | IMPORTING = "importing" 24 | IMPORT_COMPLETE = "import-complete" 25 | COMPLETE = "script-complete" 26 | 27 | state.current = state.INITIALIZING 28 | 29 | http_error_messages = {} 30 | 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." 31 | http_error_messages[403] = http_error_messages[401]; # Basically the same problem. GitHub returns 403 instead to prevent abuse. 32 | 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." 33 | 34 | 35 | def init_config(): 36 | 37 | config.add_section('login') 38 | config.add_section('source') 39 | config.add_section('target') 40 | config.add_section('format') 41 | config.add_section('settings') 42 | 43 | arg_parser = argparse.ArgumentParser(description="Import issues from one GitHub repository into another.") 44 | 45 | config_group = arg_parser.add_mutually_exclusive_group(required=False) 46 | 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.") 47 | 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.") 48 | 49 | 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.") 50 | 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.") 51 | arg_parser.add_argument('-s', '--source', help="The source repository which the issues should be copied from. Should be in the format `user/repository`.") 52 | arg_parser.add_argument('-t', '--target', help="The destination repository which the issues should be copied to. Should be in the format `user/repository`.") 53 | 54 | arg_parser.add_argument('--ignore-comments', dest='ignore_comments', action='store_true', help="Do not import comments in the issue.") 55 | arg_parser.add_argument('--ignore-milestone', dest='ignore_milestone', action='store_true', help="Do not import the milestone attached to the issue.") 56 | arg_parser.add_argument('--ignore-labels', dest='ignore_labels', action='store_true', help="Do not import labels attached to the issue.") 57 | 58 | arg_parser.add_argument('--issue-template', help="Specify a template file for use with issues.") 59 | arg_parser.add_argument('--comment-template', help="Specify a template file for use with comments.") 60 | arg_parser.add_argument('--pull-request-template', help="Specify a template file for use with pull requests.") 61 | 62 | include_group = arg_parser.add_mutually_exclusive_group(required=True) 63 | include_group.add_argument("--all", dest='import_all', action='store_true', help="Import all issues, regardless of state.") 64 | include_group.add_argument("--open", dest='import_open', action='store_true', help="Import only open issues.") 65 | include_group.add_argument("--closed", dest='import_closed', action='store_true', help="Import only closed issues.") 66 | include_group.add_argument("-i", "--issues", type=int, nargs='+', help="The list of issues to import."); 67 | 68 | args = arg_parser.parse_args() 69 | 70 | def load_config_file(config_file_name): 71 | try: 72 | config_file = open(config_file_name) 73 | config.read_file(config_file) 74 | return True 75 | except (FileNotFoundError, IOError): 76 | return False 77 | 78 | if args.no_config: 79 | print("Ignoring default config file. You may be prompted for some missing settings.") 80 | elif args.config: 81 | config_file_name = args.config 82 | if load_config_file(config_file_name): 83 | print("Loaded config options from '%s'" % config_file_name) 84 | else: 85 | sys.exit("ERROR: Unable to find or open config file '%s'" % config_file_name) 86 | else: 87 | config_file_name = default_config_file 88 | if load_config_file(config_file_name): 89 | print("Loaded options from default config file in '%s'" % config_file_name) 90 | else: 91 | print("Default config file not found in '%s'" % config_file_name) 92 | print("You may be prompted for some missing settings.") 93 | 94 | 95 | if args.username: config.set('login', 'username', args.username) 96 | if args.password: config.set('login', 'password', args.password) 97 | 98 | if args.source: config.set('source', 'repository', args.source) 99 | if args.target: config.set('target', 'repository', args.target) 100 | 101 | if args.issue_template: config.set('format', 'issue_template', args.issue_template) 102 | if args.comment_template: config.set('format', 'comment_template', args.comment_template) 103 | if args.pull_request_template: config.set('format', 'pull_request_template', args.pull_request_template) 104 | 105 | config.set('settings', 'import-comments', str(not args.ignore_comments)) 106 | config.set('settings', 'import-milestone', str(not args.ignore_milestone)) 107 | config.set('settings', 'import-labels', str(not args.ignore_labels)) 108 | 109 | config.set('settings', 'import-open-issues', str(args.import_all or args.import_open)); 110 | config.set('settings', 'import-closed-issues', str(args.import_all or args.import_closed)); 111 | 112 | 113 | # Make sure no required config values are missing 114 | if not config.has_option('source', 'repository') : 115 | sys.exit("ERROR: There is no source repository specified either in the config file, or as an argument.") 116 | if not config.has_option('target', 'repository') : 117 | sys.exit("ERROR: There is no target repository specified either in the config file, or as an argument.") 118 | 119 | 120 | def get_server_for(which): 121 | # Default to 'github.com' if no server is specified 122 | if (not config.has_option(which, 'server')): 123 | config.set(which, 'server', "github.com") 124 | 125 | # if SOURCE server is not github.com, then assume ENTERPRISE github (yourdomain.com/api/v3...) 126 | if (config.get(which, 'server') == "github.com") : 127 | api_url = "https://api.github.com" 128 | else: 129 | api_url = "https://%s/api/v3" % config.get(which, 'server') 130 | 131 | config.set(which, 'url', "%s/repos/%s" % (api_url, config.get(which, 'repository'))) 132 | 133 | get_server_for('source') 134 | get_server_for('target') 135 | 136 | 137 | # Prompt for username/password if none is provided in either the config or an argument 138 | def get_credentials_for(which): 139 | if not config.has_option(which, 'username'): 140 | if config.has_option('login', 'username'): 141 | config.set(which, 'username', config.get('login', 'username')) 142 | elif ( (which == 'target') and query.yes_no("Do you wish to use the same credentials for the target repository?") ): 143 | config.set('target', 'username', config.get('source', 'username')) 144 | else: 145 | query_str = "Enter your username for '%s' at '%s': " % (config.get(which, 'repository'), config.get(which, 'server')) 146 | config.set(which, 'username', query.username(query_str)) 147 | 148 | if not config.has_option(which, 'password'): 149 | if config.has_option('login', 'password'): 150 | config.set(which, 'password', config.get('login', 'password')) 151 | elif ( (which == 'target') and config.get('source', 'username') == config.get('target', 'username') and config.get('source', 'server') == config.get('target', 'server') ): 152 | config.set('target', 'password', config.get('source', 'password')) 153 | else: 154 | query_str = "Enter your password for '%s' at '%s': " % (config.get(which, 'repository'), config.get(which, 'server')) 155 | config.set(which, 'password', query.password(query_str)) 156 | 157 | get_credentials_for('source') 158 | get_credentials_for('target') 159 | 160 | # Everything is here! Continue on our merry way... 161 | return args.issues or [] 162 | 163 | def format_date(datestring): 164 | # The date comes from the API in ISO-8601 format 165 | date = datetime.datetime.strptime(datestring, "%Y-%m-%dT%H:%M:%SZ") 166 | date_format = config.get('format', 'date', fallback='%A %b %d, %Y at %H:%M GMT', raw=True); 167 | return date.strftime(date_format) 168 | 169 | def format_from_template(template_filename, template_data): 170 | from string import Template 171 | template_file = open(template_filename, 'r') 172 | template = Template(template_file.read()) 173 | return template.substitute(template_data) 174 | 175 | def format_issue(template_data): 176 | default_template = os.path.join(__location__, 'templates', 'issue.md') 177 | template = config.get('format', 'issue_template', fallback=default_template) 178 | return format_from_template(template, template_data) 179 | 180 | def format_pull_request(template_data): 181 | default_template = os.path.join(__location__, 'templates', 'pull_request.md') 182 | template = config.get('format', 'pull_request_template', fallback=default_template) 183 | return format_from_template(template, template_data) 184 | 185 | def format_comment(template_data): 186 | default_template = os.path.join(__location__, 'templates', 'comment.md') 187 | template = config.get('format', 'comment_template', fallback=default_template) 188 | return format_from_template(template, template_data) 189 | 190 | def send_request(which, url, post_data=None): 191 | 192 | if post_data is not None: 193 | post_data = json.dumps(post_data).encode("utf-8") 194 | 195 | full_url = "%s/%s" % (config.get(which, 'url'), url) 196 | req = urllib.request.Request(full_url, post_data) 197 | 198 | username = config.get(which, 'username') 199 | password = config.get(which, 'password') 200 | req.add_header("Authorization", b"Basic " + base64.urlsafe_b64encode(username.encode("utf-8") + b":" + password.encode("utf-8"))) 201 | 202 | req.add_header("Content-Type", "application/json") 203 | req.add_header("Accept", "application/json") 204 | req.add_header("User-Agent", "IQAndreas/github-issues-import") 205 | 206 | try: 207 | response = urllib.request.urlopen(req) 208 | json_data = response.read() 209 | except urllib.error.HTTPError as error: 210 | 211 | error_details = error.read(); 212 | error_details = json.loads(error_details.decode("utf-8")) 213 | 214 | if error.code in http_error_messages: 215 | sys.exit(http_error_messages[error.code]) 216 | else: 217 | error_message = "ERROR: There was a problem importing the issues.\n%s %s" % (error.code, error.reason) 218 | if 'message' in error_details: 219 | error_message += "\nDETAILS: " + error_details['message'] 220 | sys.exit(error_message) 221 | 222 | return json.loads(json_data.decode("utf-8")) 223 | 224 | def get_milestones(which): 225 | return send_request(which, "milestones?state=open") 226 | 227 | def get_labels(which): 228 | return send_request(which, "labels") 229 | 230 | def get_issue_by_id(which, issue_id): 231 | return send_request(which, "issues/%d" % issue_id) 232 | 233 | def get_issues_by_id(which, issue_ids): 234 | # Populate issues based on issue IDs 235 | issues = [] 236 | for issue_id in issue_ids: 237 | issues.append(get_issue_by_id(which, int(issue_id))) 238 | 239 | return issues 240 | 241 | # Allowed values for state are 'open' and 'closed' 242 | def get_issues_by_state(which, state): 243 | issues = [] 244 | page = 1 245 | while True: 246 | new_issues = send_request(which, "issues?state=%s&direction=asc&page=%d" % (state, page)) 247 | if not new_issues: 248 | break 249 | issues.extend(new_issues) 250 | page += 1 251 | return issues 252 | 253 | def get_comments_on_issue(which, issue): 254 | if issue['comments'] != 0: 255 | return send_request(which, "issues/%s/comments" % issue['number']) 256 | else : 257 | return [] 258 | 259 | def import_milestone(source): 260 | data = { 261 | "title": source['title'], 262 | "state": "open", 263 | "description": source['description'], 264 | "due_on": source['due_on'] 265 | } 266 | 267 | result_milestone = send_request('target', "milestones", source) 268 | print("Successfully created milestone '%s'" % result_milestone['title']) 269 | return result_milestone 270 | 271 | def import_label(source): 272 | data = { 273 | "name": source['name'], 274 | "color": source['color'] 275 | } 276 | 277 | result_label = send_request('target', "labels", source) 278 | print("Successfully created label '%s'" % result_label['name']) 279 | return result_label 280 | 281 | def import_comments(comments, issue_number): 282 | result_comments = [] 283 | for comment in comments: 284 | 285 | template_data = {} 286 | template_data['user_name'] = comment['user']['login'] 287 | template_data['user_url'] = comment['user']['html_url'] 288 | template_data['user_avatar'] = comment['user']['avatar_url'] 289 | template_data['date'] = format_date(comment['created_at']) 290 | template_data['url'] = comment['html_url'] 291 | template_data['body'] = comment['body'] 292 | 293 | comment['body'] = format_comment(template_data) 294 | 295 | result_comment = send_request('target', "issues/%s/comments" % issue_number, comment) 296 | result_comments.append(result_comment) 297 | 298 | return result_comments 299 | 300 | # Will only import milestones and issues that are in use by the imported issues, and do not exist in the target repository 301 | def import_issues(issues): 302 | 303 | state.current = state.GENERATING 304 | 305 | known_milestones = get_milestones('target') 306 | def get_milestone_by_title(title): 307 | for milestone in known_milestones: 308 | if milestone['title'] == title : return milestone 309 | return None 310 | 311 | known_labels = get_labels('target') 312 | def get_label_by_name(name): 313 | for label in known_labels: 314 | if label['name'] == name : return label 315 | return None 316 | 317 | new_issues = [] 318 | num_new_comments = 0 319 | new_milestones = [] 320 | new_labels = [] 321 | 322 | for issue in issues: 323 | 324 | new_issue = {} 325 | new_issue['title'] = issue['title'] 326 | 327 | # Temporary fix for marking closed issues 328 | if issue['closed_at']: 329 | new_issue['title'] = "[CLOSED] " + new_issue['title'] 330 | 331 | if config.getboolean('settings', 'import-comments') and 'comments' in issue and issue['comments'] != 0: 332 | num_new_comments += int(issue['comments']) 333 | new_issue['comments'] = get_comments_on_issue('source', issue) 334 | 335 | if config.getboolean('settings', 'import-milestone') and 'milestone' in issue and issue['milestone'] is not None: 336 | # Since the milestones' ids are going to differ, we will compare them by title instead 337 | found_milestone = get_milestone_by_title(issue['milestone']['title']) 338 | if found_milestone: 339 | new_issue['milestone_object'] = found_milestone 340 | else: 341 | new_milestone = issue['milestone'] 342 | new_issue['milestone_object'] = new_milestone 343 | known_milestones.append(new_milestone) # Allow it to be found next time 344 | new_milestones.append(new_milestone) # Put it in a queue to add it later 345 | 346 | if config.getboolean('settings', 'import-labels') and 'labels' in issue and issue['labels'] is not None: 347 | new_issue['label_objects'] = [] 348 | for issue_label in issue['labels']: 349 | found_label = get_label_by_name(issue_label['name']) 350 | if found_label: 351 | new_issue['label_objects'].append(found_label) 352 | else: 353 | new_issue['label_objects'].append(issue_label) 354 | known_labels.append(issue_label) # Allow it to be found next time 355 | new_labels.append(issue_label) # Put it in a queue to add it later 356 | 357 | template_data = {} 358 | template_data['user_name'] = issue['user']['login'] 359 | template_data['user_url'] = issue['user']['html_url'] 360 | template_data['user_avatar'] = issue['user']['avatar_url'] 361 | template_data['date'] = format_date(issue['created_at']) 362 | template_data['url'] = issue['html_url'] 363 | template_data['body'] = issue['body'] 364 | 365 | if "pull_request" in issue and issue['pull_request']['html_url'] is not None: 366 | new_issue['body'] = format_pull_request(template_data) 367 | else: 368 | new_issue['body'] = format_issue(template_data) 369 | 370 | new_issues.append(new_issue) 371 | 372 | state.current = state.IMPORT_CONFIRMATION 373 | 374 | print("You are about to add to '" + config.get('target', 'repository') + "':") 375 | print(" *", len(new_issues), "new issues") 376 | print(" *", num_new_comments, "new comments") 377 | print(" *", len(new_milestones), "new milestones") 378 | print(" *", len(new_labels), "new labels") 379 | if not query.yes_no("Are you sure you wish to continue?"): 380 | sys.exit() 381 | 382 | state.current = state.IMPORTING 383 | 384 | for milestone in new_milestones: 385 | result_milestone = import_milestone(milestone) 386 | milestone['number'] = result_milestone['number'] 387 | milestone['url'] = result_milestone['url'] 388 | 389 | for label in new_labels: 390 | result_label = import_label(label) 391 | 392 | result_issues = [] 393 | for issue in new_issues: 394 | 395 | if 'milestone_object' in issue: 396 | issue['milestone'] = issue['milestone_object']['number'] 397 | del issue['milestone_object'] 398 | 399 | if 'label_objects' in issue: 400 | issue_labels = [] 401 | for label in issue['label_objects']: 402 | issue_labels.append(label['name']) 403 | issue['labels'] = issue_labels 404 | del issue['label_objects'] 405 | 406 | result_issue = send_request('target', "issues", issue) 407 | print("Successfully created issue '%s'" % result_issue['title']) 408 | 409 | if 'comments' in issue: 410 | result_comments = import_comments(issue['comments'], result_issue['number']) 411 | print(" > Successfully added", len(result_comments), "comments.") 412 | 413 | result_issues.append(result_issue) 414 | 415 | state.current = state.IMPORT_COMPLETE 416 | 417 | return result_issues 418 | 419 | 420 | if __name__ == '__main__': 421 | 422 | state.current = state.LOADING_CONFIG 423 | 424 | issue_ids = init_config() 425 | issues = [] 426 | 427 | state.current = state.FETCHING_ISSUES 428 | 429 | # Argparser will prevent us from getting both issue ids and specifying issue state, so no duplicates will be added 430 | if (len(issue_ids) > 0): 431 | issues += get_issues_by_id('source', issue_ids) 432 | 433 | if config.getboolean('settings', 'import-open-issues'): 434 | issues += get_issues_by_state('source', 'open') 435 | 436 | if config.getboolean('settings', 'import-closed-issues'): 437 | issues += get_issues_by_state('source', 'closed') 438 | 439 | # Sort issues based on their original `id` field 440 | # Confusing, but taken from http://stackoverflow.com/a/2878123/617937 441 | issues.sort(key=lambda x:x['number']) 442 | 443 | # Further states defined within the function 444 | # Finally, add these issues to the target repository 445 | import_issues(issues) 446 | 447 | state.current = state.COMPLETE 448 | 449 | 450 | -------------------------------------------------------------------------------- /query.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | 5 | def username(question): 6 | # Reserve this are in case I want to prevent special characters etc in the future 7 | return input(question) 8 | 9 | def password(question): 10 | import getpass 11 | return getpass.getpass(question) 12 | 13 | # Taken from http://code.activestate.com/recipes/577058-query-yesno/ 14 | # with some personal modifications 15 | def yes_no(question, default=True): 16 | choices = {"yes":True, "y":True, "ye":True, 17 | "no":False, "n":False } 18 | 19 | if default == None: 20 | prompt = " [y/n] " 21 | elif default == True: 22 | prompt = " [Y/n] " 23 | elif default == False: 24 | prompt = " [y/N] " 25 | else: 26 | raise ValueError("invalid default answer: '%s'" % default) 27 | 28 | while 1: 29 | sys.stdout.write(question + prompt) 30 | choice = input().lower() 31 | if default is not None and choice == '': 32 | return default 33 | elif choice in choices.keys(): 34 | return choices[choice] 35 | else: 36 | sys.stdout.write("Please respond with 'yes' or 'no' "\ 37 | "(or 'y' or 'n').\n") 38 | 39 | -------------------------------------------------------------------------------- /templates/comment.md: -------------------------------------------------------------------------------- 1 | **Comment by [${user_name}](${user_url})** 2 | _${date}_ 3 | 4 | ---- 5 | 6 | ${body} 7 | -------------------------------------------------------------------------------- /templates/issue.md: -------------------------------------------------------------------------------- 1 | **Issue by [${user_name}](${user_url})** 2 | _${date}_ 3 | _Originally opened as ${url}_ 4 | 5 | ---- 6 | 7 | ${body} 8 | -------------------------------------------------------------------------------- /templates/pull_request.md: -------------------------------------------------------------------------------- 1 | **Issue by [${user_name}](${user_url})** 2 | _${date}_ 3 | _Originally opened as ${url}_ 4 | 5 | ---- 6 | 7 | ${body} 8 | 9 | ---- 10 | 11 | _**[${user_name}](${user_url})** included the following code: ${url}/commits_ 12 | --------------------------------------------------------------------------------