1#!/usr/bin/env python3 2 3import re 4import urllib.request 5import urllib.parse 6import urllib.error 7import json 8import itertools 9import logging 10import threading 11import collections 12import time 13import os 14import sys 15import argparse 16import pathlib 17import contextlib 18import xml.etree.ElementTree 19from datetime import time as dtime, date, datetime, timedelta 20 21 22log = logging.getLogger('generate_changelog') 23 24 25class MissingCommitException(Exception): 26 pass 27 28 29class JenkinsBuild: 30 """Representation of a Jenkins Build""" 31 32 def __init__(self, number, last_hash, branch, build_dttm, is_building, 33 build_result, block_ms, wait_ms, build_ms): 34 self.number = number 35 self.last_hash = last_hash 36 self.branch = branch 37 self.build_dttm = build_dttm 38 self.is_building = is_building 39 self.build_result = build_result 40 self.wait_ms = wait_ms 41 self.build_ms = build_ms 42 self.block_ms = block_ms 43 44 def was_successful(self): 45 return not self.is_building and self.build_result == 'SUCCESS' 46 47 def __str__(self): 48 return (f'{self.__class__.__name__}[{self.number} - ' 49 f'{self.last_hash} - {self.build_dttm} - ' 50 f'{self.build_result}]') 51 52 53class Commit(object): 54 """Representation of a generic GitHub Commit""" 55 56 def __init__(self, hash_id, message, commit_dttm, author, parents): 57 self.hash = hash_id 58 self.message = message 59 self.commit_dttm = commit_dttm 60 self.author = author 61 self.parents = parents 62 63 @property 64 def commit_date(self): 65 return self.commit_dttm.date() 66 67 def committed_after(self, commit_dttm): 68 return self.commit_dttm is not None and self.commit_dttm >= commit_dttm 69 70 def committed_before(self, commit_dttm): 71 return self.commit_dttm is not None and self.commit_dttm <= commit_dttm 72 73 @property 74 def is_merge(self): 75 return len(self.parents) > 1 76 77 def __str__(self): 78 return (f'{self.__class__.__name__}[{self.hash} - ' 79 f'{self.commit_dttm} - {self.message} BY {self.author}]') 80 81 82class PullRequest(object): 83 """Representation of a generic GitHub Pull Request""" 84 85 def __init__(self, pr_id, title, author, state, body, merge_hash, 86 merge_dttm, update_dttm): 87 self.id = pr_id 88 self.author = author 89 self.title = title 90 self.body = body 91 self.state = state 92 self.merge_hash = merge_hash 93 self.merge_dttm = merge_dttm 94 self.update_dttm = update_dttm 95 96 @property 97 def update_date(self): 98 return self.update_dttm.date() 99 100 @property 101 def merge_date(self): 102 return self.merge_dttm.date() 103 104 @property 105 def is_merged(self): 106 return self.merge_dttm is not None 107 108 def merged_after(self, merge_dttm): 109 return self.merge_dttm is not None and self.merge_dttm >= merge_dttm 110 111 def merged_before(self, merge_dttm): 112 return self.merge_dttm is not None and self.merge_dttm <= merge_dttm 113 114 def updated_after(self, update_dttm): 115 return self.update_dttm is not None and self.update_dttm >= update_dttm 116 117 def updated_before(self, update_dttm): 118 return self.update_dttm is not None and self.update_dttm <= update_dttm 119 120 def __str__(self): 121 return (f'{self.__class__.__name__}[{self.id} - {self.merge_dttm} - ' 122 f'{self.title} BY {self.author}]') 123 124 125class SummaryType: 126 """Different valid Summary Types. Intended to be used as a enum/constant 127 class, no instantiation needed.""" 128 129 NONE = 'NONE' 130 FEATURES = 'FEATURES' 131 CONTENT = 'CONTENT' 132 INTERFACE = 'INTERFACE' 133 MODS = 'MODS' 134 BALANCE = 'BALANCE' 135 BUGFIXES = 'BUGFIXES' 136 PERFORMANCE = 'PERFORMANCE' 137 INFRASTRUCTURE = 'INFRASTRUCTURE' 138 BUILD = 'BUILD' 139 I18N = 'I18N' 140 141 142class CDDAPullRequest(PullRequest): 143 """A Pull Request with logic specific to CDDA Repository and their 144 "Summary" descriptions""" 145 146 SUMMARY_REGEX = re.compile( 147 r'(?i:####\sSummary)\s*' 148 r'`*(?i:SUMMARY:?\s*)?(?P<pr_type>\w+)\s*(?:"(?P<pr_desc>.+)")?', 149 re.MULTILINE) 150 151 VALID_SUMMARY_CATEGORIES = ( 152 'Content', 153 'Features', 154 'Interface', 155 'Mods', 156 'Balance', 157 'I18N', 158 'Bugfixes', 159 'Performance', 160 'Build', 161 'Infrastructure', 162 'None', 163 ) 164 165 EXAMPLE_SUMMARIES_IN_TEMPLATE = ( 166 ("Category", "description"), 167 ("Category", "Brief description"), 168 ("Content", "Adds new mutation category 'Mouse'"), 169 ) 170 171 def __init__(self, pr_id, title, author, state, body, merge_hash, 172 merge_dttm, update_dttm, store_body=False): 173 super().__init__( 174 pr_id, title, author, state, body if store_body else '', 175 merge_hash, merge_dttm, update_dttm) 176 self.summ_type, self.summ_desc = self._get_summary(body) 177 178 @property 179 def summ_type(self): 180 return self._summ_type 181 182 @summ_type.setter 183 def summ_type(self, value): 184 self._summ_type = value.upper() if value is not None else None 185 186 @property 187 def has_valid_summary(self): 188 return (self.summ_type == SummaryType.NONE or 189 (self.summ_type is not None and self.summ_desc is not None)) 190 191 def _get_summary(self, body): 192 matches = list(re.finditer(self.SUMMARY_REGEX, body)) 193 # Fix weird cases where a PR have multiple SUMMARY 194 # coming mostly from template lines that weren't removed by the pull 195 # requester. For example: 196 # https://api.github.com/repos/CleverRaven/Cataclysm-DDA/pulls/25604 197 198 def summary_filter(x): 199 return ((x.group('pr_type'), x.group('pr_desc')) not in 200 self.EXAMPLE_SUMMARIES_IN_TEMPLATE) 201 matches = list(filter(summary_filter, matches)) 202 if len(matches) > 1: 203 log.warning(f'More than one SUMMARY defined in PR {self.id}!') 204 205 match = matches[0] if matches else None 206 upper_cats = (x.upper() for x in self.VALID_SUMMARY_CATEGORIES) 207 if match is None or match.group('pr_type').upper() not in upper_cats: 208 return None, None 209 else: 210 return match.group('pr_type'), match.group('pr_desc') 211 212 def __str__(self): 213 if self.has_valid_summary and self.summ_type == SummaryType.NONE: 214 return (f'{self.__class__.__name__}' 215 f'[{self.id} - {self.merge_dttm} - {self.summ_type} - ' 216 f'{self.title} BY {self.author}]') 217 elif self.has_valid_summary: 218 return (f'{self.__class__.__name__}' 219 f'[{self.id} - {self.merge_dttm} - {self.summ_type} - ' 220 f'{self.summ_desc} BY {self.author}]') 221 else: 222 return (f'{self.__class__.__name__}' 223 f'[{self.id} - {self.merge_dttm} - ' 224 f'{self.title} BY {self.author}]') 225 226 227class JenkinsBuildFactory: 228 """Abstraction for instantiation of new Commit objects""" 229 230 def create(self, number, last_hash, branch, build_dttm, is_building, 231 build_result, block_ms, wait_ms, build_ms): 232 return JenkinsBuild(number, last_hash, branch, build_dttm, is_building, 233 build_result, block_ms, wait_ms, build_ms) 234 235 236class CommitFactory: 237 """Abstraction for instantiation of new Commit objects""" 238 239 def create(self, hash_id, message, commit_date, author, parents): 240 return Commit(hash_id, message, commit_date, author, parents) 241 242 243class CDDAPullRequestFactory: 244 """Abstraction for instantiation of new CDDAPullRequests objects""" 245 246 def __init__(self, store_body=False): 247 self.store_body = store_body 248 249 def create(self, pr_id, title, author, state, body, merge_hash, merge_dttm, 250 update_dttm): 251 return CDDAPullRequest(pr_id, title, author, state, body, merge_hash, 252 merge_dttm, update_dttm, self.store_body) 253 254 255class CommitRepository: 256 """Groups Commits for storage and common operations""" 257 258 def __init__(self): 259 self.ref_by_commit_hash = {} 260 self._latest_commit = None 261 262 def add(self, commit): 263 is_newer = (self._latest_commit is None or 264 self._latest_commit.commit_date < commit.commit_date) 265 if is_newer: 266 self._latest_commit = commit 267 self.ref_by_commit_hash[commit.hash] = commit 268 269 def add_multiple(self, commits): 270 for commit in commits: 271 self.add(commit) 272 273 def get_commit(self, commit_hash): 274 if commit_hash in self.ref_by_commit_hash: 275 return self.ref_by_commit_hash[commit_hash] 276 return None 277 278 def get_latest_commit(self): 279 return self._latest_commit 280 281 def get_all_commits(self, filter_by=None, sort_by=None): 282 """Return all Commits in Repository. No order is guaranteed.""" 283 commit_list = self.ref_by_commit_hash.values() 284 285 if filter_by is not None: 286 commit_list = filter(filter_by, commit_list) 287 288 if sort_by is not None: 289 commit_list = sorted(commit_list, key=sort_by) 290 291 for commit in commit_list: 292 yield commit 293 294 def traverse_commits_by_first_parent(self, initial_hash=None): 295 """Iterate through Commits connected by the first Parent, until a 296 parent is not found in the Repository 297 298 This is like using 'git log --first-parent $commit', which avoids 299 Commits "inside" Pull Requests / Merges. 300 But returns Merge Commits (which often can be related to a single PR) 301 and Commits directly added to a Branch. 302 """ 303 if initial_hash is not None: 304 commit = self.get_commit(initial_hash) 305 else: 306 commit = self.get_latest_commit() 307 308 while commit is not None: 309 yield commit 310 commit = self.get_commit(commit.parents[0]) 311 312 def get_commit_range_by_hash(self, latest_hash, oldest_hash): 313 """Return Commits between initial_hash (including) and oldest_hash 314 (excluding) connected by the first Parent.""" 315 for commit in self.traverse_commits_by_first_parent(latest_hash): 316 if commit.hash == oldest_hash: 317 return 318 yield commit 319 # consumed the whole commit list in the Repo and didn't find 320 # oldest_hash so returned commit list is incomplete 321 raise MissingCommitException( 322 "Can't generate commit list for specified hash range." 323 " There are missing Commits in CommitRepository." 324 ) 325 326 def get_commit_range_by_date(self, latest_dttm, oldest_dttm): 327 """Return Commits between latest_dttm (including) and oldest_dttm 328 (excluding) connected by the first Parent.""" 329 for commit in self.traverse_commits_by_first_parent(): 330 # assuming that traversing by first parent have chronological order 331 # (should be DESC BY commit_dttm) 332 if commit.commit_dttm <= oldest_dttm: 333 return 334 if latest_dttm >= commit.commit_dttm > oldest_dttm: 335 yield commit 336 # consumed the whole commit list and didn't find a commit past our 337 # build. So returned commit list *could be* incomplete 338 raise MissingCommitException( 339 "Can't generate commit list for specified date range." 340 " There are missing Commits in CommitRepository." 341 ) 342 343 def purge_references(self): 344 self.ref_by_commit_hash.clear() 345 346 347class CDDAPullRequestRepository: 348 """Groups Pull Requests for storage and common operations""" 349 350 def __init__(self): 351 self.ref_by_pr_id = {} 352 self.ref_by_merge_hash = {} 353 354 def add(self, pull_request): 355 self.ref_by_pr_id[pull_request.id] = pull_request 356 if pull_request.merge_hash is not None: 357 self.ref_by_merge_hash[pull_request.merge_hash] = pull_request 358 359 def add_multiple(self, pull_requests): 360 for pr in pull_requests: 361 self.add(pr) 362 363 def get_pr_by_merge_hash(self, merge_hash): 364 if merge_hash in self.ref_by_merge_hash: 365 return self.ref_by_merge_hash[merge_hash] 366 return None 367 368 def get_all_pr(self, filter_by=None, sort_by=None): 369 """Return all Pull Requests in Repository. No order is guaranteed.""" 370 pr_list = self.ref_by_pr_id.values() 371 372 if filter_by is not None: 373 pr_list = filter(filter_by, pr_list) 374 375 if sort_by is not None: 376 pr_list = sorted(pr_list, key=sort_by) 377 378 for pr in pr_list: 379 yield pr 380 381 def get_merged_pr_list_by_date(self, latest_dttm, oldest_dttm): 382 """Return PullRequests merged between latest_dttm (including) and 383 oldest_dttm (excluding).""" 384 for pr in self.get_all_pr(filter_by=lambda x: x.is_merged, 385 sort_by=lambda x: -x.merge_dttm.timestamp()): 386 if latest_dttm >= pr.merge_dttm > oldest_dttm: 387 yield pr 388 389 def purge_references(self): 390 self.ref_by_merge_hash.clear() 391 392 393class JenkinsBuildRepository: 394 """Groups JenkinsBuilds for storage and common operations""" 395 396 def __init__(self): 397 self.ref_by_build_number = {} 398 399 def add(self, build): 400 if build.number is not None: 401 self.ref_by_build_number[build.number] = build 402 403 def add_multiple(self, builds): 404 for build in builds: 405 self.add(build) 406 407 def get_build_by_number(self, build_number): 408 if build_number in self.ref_by_build_number: 409 return self.ref_by_build_number[build_number] 410 return None 411 412 def get_previous_build(self, build_number, condition=lambda x: True): 413 for x in range(build_number - 1, 0, -1): 414 prev_build = self.get_build_by_number(x) 415 if prev_build is not None and condition(prev_build): 416 return prev_build 417 return None 418 419 def get_previous_successful_build(self, build_number): 420 return self.get_previous_build(build_number, 421 lambda x: x.was_successful()) 422 423 def get_all_builds(self, filter_by=None, sort_by=None): 424 """Return all Builds in Repository. No order is guaranteed.""" 425 build_list = self.ref_by_build_number.values() 426 427 if filter_by is not None: 428 build_list = filter(filter_by, build_list) 429 430 if sort_by is not None: 431 build_list = sorted(build_list, key=sort_by) 432 433 for build in build_list: 434 yield build 435 436 def purge_references(self): 437 self.ref_by_build_number.clear() 438 439 440class JenkinsApi: 441 442 JENKINS_BUILD_LIST_API = \ 443 r'http://gorgon.narc.ro:8080/job/Cataclysm-Matrix/api/xml' 444 445 JENKINS_BUILD_LONG_LIST_PARAMS = { 446 'tree': r'allBuilds[number,timestamp,building,result,actions[buildingDurationMillis,waitingDurationMillis,blockedDurationMillis,lastBuiltRevision[branch[name,SHA1]]]]', # noqa: E501 447 'xpath': r'//allBuild', 448 'wrapper': r'allBuilds', 449 'exclude': r'//action[not(buildingDurationMillis|waitingDurationMillis|blockedDurationMillis|lastBuiltRevision/branch)]' # noqa: E501 450 } 451 452 JENKINS_BUILD_SHORT_LIST_PARAMS = { 453 'tree': r'builds[number,timestamp,building,result,actions[buildingDurationMillis,waitingDurationMillis,blockedDurationMillis,lastBuiltRevision[branch[name,SHA1]]]]', # noqa: E501 454 'xpath': r'//build', 455 'wrapper': r'builds', 456 'exclude': r'//action[not(buildingDurationMillis|waitingDurationMillis|blockedDurationMillis|lastBuiltRevision/branch)]' # noqa: E501 457 } 458 459 def __init__(self, build_factory): 460 self.build_factory = build_factory 461 462 def get_build_list(self): 463 """Return the builds from Jenkins. API limits the result to last 999 464 builds.""" 465 request_url = (self.JENKINS_BUILD_LIST_API + '?' + 466 urllib.parse.urlencode( 467 self.JENKINS_BUILD_LONG_LIST_PARAMS)) 468 api_request = urllib.request.Request(request_url) 469 log.debug(f'Processing Request {api_request.full_url}') 470 with urllib.request.urlopen(api_request) as api_response: 471 api_data = xml.etree.ElementTree.fromstring(api_response.read()) 472 log.debug('Jenkins Request DONE!') 473 474 for build_data in api_data: 475 yield self._create_build_from_api_data(build_data) 476 477 def _create_build_from_api_data(self, build_data): 478 """Create a JenkinsBuild instance based on data from Jenkins API""" 479 jb_number = int(build_data.find('number').text) 480 jb_build_dttm = datetime.utcfromtimestamp( 481 int(build_data.find('timestamp').text) // 1000) 482 jb_is_building = build_data.find('building').text == 'true' 483 484 jb_block_ms = timedelta(0) 485 jb_wait_ms = timedelta(0) 486 jb_build_ms = timedelta(0) 487 jb_build_result = None 488 if not jb_is_building: 489 jb_build_result = build_data.find('result').text 490 jb_block_ms = timedelta(milliseconds=int( 491 build_data.find(r'.//action/blockedDurationMillis').text)) 492 jb_wait_ms = timedelta(milliseconds=int( 493 build_data.find(r'.//action/waitingDurationMillis').text)) 494 jb_build_ms = timedelta(milliseconds=int( 495 build_data.find(r'.//action/buildingDurationMillis').text)) 496 497 jb_last_hash = None 498 jb_branch = None 499 sha1 = build_data.find(r'.//action/lastBuiltRevision/branch/SHA1') 500 if sha1 is not None: 501 jb_last_hash = sha1.text 502 jb_branch = build_data.find( 503 r'.//action/lastBuiltRevision/branch/name').text 504 505 return self.build_factory.create( 506 jb_number, jb_last_hash, jb_branch, jb_build_dttm, jb_is_building, 507 jb_build_result, jb_block_ms, jb_wait_ms, jb_build_ms) 508 509 510class CommitApi: 511 512 def __init__(self, commit_factory, api_token): 513 self.commit_factory = commit_factory 514 self.api_token = api_token 515 516 def get_commit_list(self, min_commit_dttm, max_commit_dttm, 517 branch='master', max_threads=15): 518 """Return a list of Commits from specified commit date up to now. Order 519 is not guaranteed by threads. 520 521 params: 522 min_commit_dttm = None or minimum commit date to be part of the 523 result set (UTC+0 timezone) 524 max_commit_dttm = None or maximum commit date to be part of the 525 result set (UTC+0 timezone) 526 """ 527 if min_commit_dttm is None: 528 min_commit_dttm = datetime.min 529 if max_commit_dttm is None: 530 max_commit_dttm = datetime.utcnow() 531 532 results_queue = collections.deque() 533 request_generator = CommitApiGenerator(self.api_token, min_commit_dttm, 534 max_commit_dttm, branch) 535 threaded = MultiThreadedGitHubApi() 536 threaded.process_api_requests( 537 request_generator, 538 self._process_commit_data_callback(results_queue), 539 max_threads=max_threads) 540 541 return (commit for commit in results_queue 542 if commit.committed_after(min_commit_dttm) and 543 commit.committed_before(max_commit_dttm)) 544 545 def _process_commit_data_callback(self, results_queue): 546 """Returns a callback that will process data into Commits instances and 547 stop threads when needed.""" 548 def _process_commit_data_callback_closure(json_data, request_generator, 549 api_request): 550 nonlocal results_queue 551 commit_list = [ 552 self._create_commit_from_api_data(x) for x in json_data] 553 for commit in commit_list: 554 results_queue.append(commit) 555 556 if request_generator.is_active and len(commit_list) == 0: 557 log.debug(f'Target page found, stop giving threads more ' 558 f'requests. Target URL: {api_request.full_url}') 559 request_generator.deactivate() 560 return _process_commit_data_callback_closure 561 562 def _create_commit_from_api_data(self, commit_data): 563 """Create a Commit instance based on data from GitHub API""" 564 if commit_data is None: 565 return None 566 commit_sha = commit_data['sha'] 567 # some commits have null in author.login :S like: 568 # https://api.github.com/repos/CleverRaven/Cataclysm-DDA/commits/569bef1891a843ec71654530a64d51939aabb3e2 569 # I try to use author.login when possible or fallback to 570 # "commit.author" which doesn't match with usernames in pull requests 571 # API (I guess it comes from the distinction between "name" and 572 # "username". 573 # I rather have a name that doesn't match that leave it empty. 574 # Anyways, I'm surprised but GitHub API sucks, is super inconsistent 575 # and not well thought or documented. 576 if commit_data['author'] is not None: 577 if 'login' in commit_data['author']: 578 commit_author = commit_data['author']['login'] 579 else: 580 commit_author = 'null' 581 else: 582 commit_author = commit_data['commit']['author']['name'] 583 if commit_data['commit']['message']: 584 commit_message = commit_data['commit']['message'].splitlines()[0] 585 else: 586 commit_message = '' 587 commit_dttm = commit_data['commit']['committer']['date'] 588 if commit_dttm: 589 commit_dttm = datetime.fromisoformat(commit_dttm.rstrip('Z')) 590 else: 591 commit_dttm = None 592 commit_parents = tuple(p['sha'] for p in commit_data['parents']) 593 594 return self.commit_factory.create( 595 commit_sha, commit_message, commit_dttm, commit_author, 596 commit_parents) 597 598 599class PullRequestApi: 600 601 GITHUB_API_SEARCH = r'https://api.github.com/search/issues' 602 603 def __init__(self, pr_factory, api_token): 604 self.pr_factory = pr_factory 605 self.api_token = api_token 606 607 def search_for_pull_request(self, commit_hash): 608 """Returns the Pull Request ID of a specific Commit Hash using a Search 609 API in GitHub 610 611 AVOID AT ALL COST: You have to check Commit by Commit and is super 612 slow! 613 614 Based on 615 https://help.github.com/articles/searching-issues-and-pull-requests/#search-by-commit-sha 616 No need to make this multi-threaded as GitHub limits this to 30 617 requests per minute. 618 """ 619 params = { 620 'q': f'is:pr is:merged repo:CleverRaven/Cataclysm-DDA ' 621 f'{commit_hash}' 622 } 623 request_builder = GitHubApiRequestBuilder(self.api_token) 624 api_request = request_builder.create_request(self.GITHUB_API_SEARCH, 625 params) 626 627 api_data = do_github_request(api_request) 628 629 # I found some cases where the search found multiple PRs because of a 630 # reference of the commit hash in another PR description. But it had a 631 # huge difference in score, it seems the engine scores over 100 the one 632 # that actually has the commit hash as "Merge Commit SHA". 633 # Output example: 634 # https://api.github.com/search/issues 635 # ?q=is%3Apr+is%3Amerged+repo%3ACleverRaven%2FCataclysm-DDA+f0c6908d154cd0fb190c2116de2bf2d3131458c3 636 637 # If the API don't return a score of 100 or more for the highest score 638 # item (the first item), then the commit is probably not part of any 639 # pull request. This assumption was backed up by checking ~9 months of 640 # commits 641 if api_data['total_count'] == 0 or api_data['items'][0]['score'] < 100: 642 return None 643 else: 644 return api_data['items'][0]['number'] 645 646 def get_pr_list(self, min_dttm, max_dttm=None, state='all', 647 merged_only=False, max_threads=15): 648 """Return a list of PullRequests from specified commit date up to now. 649 Order is not guaranteed by threads. 650 651 params: 652 min_dttm = None or minimum update date on the PR to be part of 653 the result set (UTC+0 timezone) 654 max_dttm = None or maximum update date on the PR to be part of 655 the result set (UTC+0 timezone) 656 state = 'open' | 'closed' | 'all' 657 merged_only = search only 'closed' state PRs, and filter PRs by 658 merge date instead of update date 659 """ 660 if min_dttm is None: 661 min_dttm = datetime.min 662 if max_dttm is None: 663 max_dttm = datetime.utcnow() 664 if merged_only: 665 state = 'closed' 666 667 results_queue = collections.deque() 668 request_generator = PullRequestApiGenerator(self.api_token, state) 669 threaded = MultiThreadedGitHubApi() 670 threaded.process_api_requests( 671 request_generator, 672 self._process_pr_data_callback(results_queue, min_dttm), 673 max_threads=max_threads) 674 675 if merged_only: 676 return (pr for pr in results_queue 677 if pr.is_merged and pr.merged_after(min_dttm) and 678 pr.merged_before(max_dttm)) 679 else: 680 return (pr for pr in results_queue 681 if pr.updated_after(min_dttm) and 682 pr.updated_before(max_dttm)) 683 684 def _process_pr_data_callback(self, results_queue, min_dttm): 685 """Returns a callback that will process data into Pull Requests objects 686 and stop threads when needed.""" 687 def _process_pr_data_callback_closure(json_data, request_generator, 688 api_request): 689 nonlocal results_queue, min_dttm 690 pull_request_list = [ 691 self._create_pr_from_api_data(x) for x in json_data] 692 for pr in pull_request_list: 693 results_queue.append(pr) 694 695 # this check on update date makes sure we get the GitHub API pages 696 # we need a more precise PR filter of the result is made from 697 # results_queue later 698 target_page_found = not any( 699 pr.updated_after(min_dttm) for pr in pull_request_list) 700 701 if len(pull_request_list) == 0 or target_page_found: 702 if request_generator.is_active: 703 log.debug( 704 f'Target page found, stop giving threads more ' 705 f'requests. Target URL: {api_request.full_url}') 706 request_generator.deactivate() 707 708 return _process_pr_data_callback_closure 709 710 def _create_pr_from_api_data(self, pr_data): 711 """Create a PullRequest instance based on data from GitHub API""" 712 if pr_data is None: 713 return None 714 pr_number = pr_data['number'] 715 pr_author = pr_data['user']['login'] 716 pr_title = pr_data['title'] 717 pr_state = pr_data['state'] 718 pr_merge_hash = pr_data['merge_commit_sha'] 719 # python expects an ISO date with the Z to properly parse it, so I 720 # strip it. 721 if pr_data['merged_at']: 722 pr_merge_dttm = datetime.fromisoformat( 723 pr_data['merged_at'].rstrip('Z')) 724 else: 725 pr_merge_dttm = None 726 if pr_data['updated_at']: 727 pr_update_dttm = datetime.fromisoformat( 728 pr_data['updated_at'].rstrip('Z')) 729 else: 730 pr_update_dttm = None 731 # PR description can be empty :S example: 732 # https://github.com/CleverRaven/Cataclysm-DDA/pull/24213 733 pr_body = pr_data['body'] if pr_data['body'] else '' 734 735 return self.pr_factory.create( 736 pr_number, pr_title, pr_author, pr_state, pr_body, pr_merge_hash, 737 pr_merge_dttm, pr_update_dttm) 738 739 740class MultiThreadedGitHubApi: 741 742 def process_api_requests(self, request_generator, callback, 743 max_threads=15): 744 """Process HTTP API requests on threads and call the callback for each 745 result JSON 746 747 params: 748 callback = executed when data is available, should expect two 749 params: (json data, request_generator) 750 """ 751 # start threads 752 threads = [] 753 for x in range(1, max_threads + 1): 754 t = threading.Thread( 755 target=exit_on_exception( 756 self._process_api_requests_on_threads), 757 args=(request_generator, callback)) 758 t.name = f'WORKER_{x:03}' 759 threads.append(t) 760 t.daemon = True 761 t.start() 762 time.sleep(0.1) 763 764 # block waiting until threads get all results 765 for t in threads: 766 t.join() 767 log.debug('Threads have finished processing all the required GitHub ' 768 'API Requests!!!') 769 770 @staticmethod 771 def _process_api_requests_on_threads(request_generator, callback): 772 """Process HTTP API requests and call the callback for each result 773 JSON""" 774 log.debug(f'Thread Started!') 775 api_request = request_generator.generate() 776 while api_request is not None: 777 callback(do_github_request(api_request), request_generator, 778 api_request) 779 api_request = request_generator.generate() 780 log.debug(f'No more requests left, killing Thread.') 781 782 783class GitHubApiRequestBuilder(object): 784 785 def __init__(self, api_token, timezone='Etc/UTC'): 786 self.api_token = api_token 787 self.timezone = timezone 788 789 def create_request(self, url, params=None): 790 """Creates an API request based on provided URL and GET Parameters""" 791 if params is None: 792 request_url = url 793 else: 794 request_url = url + '?' + urllib.parse.urlencode(params) 795 796 if self.api_token is None: 797 api_request = urllib.request.Request(request_url) 798 else: 799 api_headers = { 800 'Authorization': 'token ' + self.api_token, 801 'Time-Zone': self.timezone, 802 } 803 api_request = urllib.request.Request( 804 request_url, headers=api_headers) 805 806 return api_request 807 808 809class CommitApiGenerator(GitHubApiRequestBuilder): 810 """Generates multiple HTTP requests to get Commits, used from Threads to 811 get data until a condition is met.""" 812 813 GITHUB_API_LIST_COMMITS = \ 814 r'https://api.github.com/repos/CleverRaven/Cataclysm-DDA/commits' 815 816 def __init__(self, api_token, since_dttm=None, until_dttm=None, 817 sha='master', initial_page=1, step=1, timezone='Etc/UTC'): 818 super().__init__(api_token, timezone) 819 self.sha = sha 820 self.since_dttm = since_dttm 821 self.until_dttm = until_dttm 822 self.page = initial_page 823 self.step = step 824 self.lock = threading.RLock() 825 self.is_active = True 826 827 @property 828 def is_active(self): 829 with self.lock: 830 return self._is_active 831 832 @is_active.setter 833 def is_active(self, value): 834 with self.lock: 835 self._is_active = value 836 837 def deactivate(self): 838 """Stop generate() from creating new HTTP requests on future calls.""" 839 self.is_active = False 840 841 def generate(self): 842 """Returns an HTTP request to get Commits for a different API result 843 page each call until deactivate().""" 844 with self.lock: 845 if self.is_active: 846 req = self.create_request(self.since_dttm, self.until_dttm, 847 self.sha, self.page) 848 self.page += self.step 849 return req 850 else: 851 return None 852 853 def create_request(self, since_dttm=None, until_dttm=None, sha='master', 854 page=1): 855 """Creates an HTTP Request to GitHub API to get Commits from CDDA 856 repository.""" 857 params = { 858 'sha': sha, 859 'page': page, 860 } 861 if since_dttm is not None: 862 params['since'] = since_dttm.isoformat() 863 if until_dttm is not None: 864 params['until'] = until_dttm.isoformat() 865 866 return super().create_request(self.GITHUB_API_LIST_COMMITS, params) 867 868 869class PullRequestApiGenerator(GitHubApiRequestBuilder): 870 """Generates multiple HTTP requests to get Pull Requests, used from Threads 871 to get data until a condition is met.""" 872 873 GITHUB_API_LIST_PR = \ 874 r'https://api.github.com/repos/CleverRaven/Cataclysm-DDA/pulls' 875 876 def __init__(self, api_token, state='all', initial_page=1, step=1, 877 timezone='Etc/UTC'): 878 super().__init__(api_token, timezone) 879 self.page = initial_page 880 self.step = step 881 self.state = state 882 self.lock = threading.RLock() 883 self.is_active = True 884 885 @property 886 def is_active(self): 887 with self.lock: 888 return self._is_active 889 890 @is_active.setter 891 def is_active(self, value): 892 with self.lock: 893 self._is_active = value 894 895 def deactivate(self): 896 """Stop generate() from creating new HTTP requests on future calls.""" 897 self.is_active = False 898 899 def generate(self): 900 """Returns an HTTP request to get Pull Requests for a different API 901 result page each call until deactivate().""" 902 with self.lock: 903 if self.is_active: 904 req = self.create_request(self.state, self.page) 905 self.page += self.step 906 return req 907 else: 908 return None 909 910 def create_request(self, state='all', page=1): 911 """Creates an HTTP Request to GitHub API to get Pull Requests from CDDA 912 repository. 913 914 params: 915 state = 'open' | 'closed' | 'all' 916 """ 917 params = { 918 'state': state, 919 'sort': 'updated', 920 'direction': 'desc', 921 'page': page, 922 } 923 return super().create_request(self.GITHUB_API_LIST_PR, params) 924 925 926def exit_on_exception(func): 927 """Decorator to terminate the main script and all threads if a thread 928 generates an Exception""" 929 def exit_on_exception_closure(*args, **kwargs): 930 try: 931 func(*args, **kwargs) 932 except Exception as err: 933 log.exception(f'Unhandled Exception: {err}') 934 os._exit(-10) 935 return exit_on_exception_closure 936 937 938def do_github_request(api_request, retry_on_limit=3): 939 """Do an HTTP request to GitHub and retries in case of hitting API 940 limits""" 941 for retry in range(1, retry_on_limit + 2): 942 try: 943 log.debug(f'Processing Request {api_request.full_url}') 944 with urllib.request.urlopen(api_request) as api_response: 945 return json.load(api_response) 946 except urllib.error.HTTPError as err: 947 # hit rate limit, wait and retry 948 is_403 = err.code == 403 949 if is_403 and err.getheader('Retry-After'): 950 wait = int(err.getheader('Retry-After')) + 5 951 log.info(f'Reached GitHub API rate limit. Retry {retry}, ' 952 f'waiting {wait} secs...') 953 time.sleep(wait) 954 elif is_403 and err.getheader('X-RateLimit-Remaining') == '0': 955 reset = int(err.getheader('X-RateLimit-Reset')) 956 delta = datetime.utcfromtimestamp(reset) - datetime.utcnow() 957 wait = delta.seconds + 5 958 log.info(f'Reached GitHub API rate limit. Retry {retry}, ' 959 f'waiting {wait} secs...') 960 time.sleep(wait) 961 else: 962 # other kind of http error, just implode 963 log.exception(f'Unhandled Exception: {err} - ' 964 f'HTTP Headers: {err.getheaders()}') 965 raise 966 raise Exception(f'Retry limit reached') 967 968 969def read_personal_token(filename): 970 """Return Personal Token from specified file, None if no file is provided 971 or file doesn't exist. 972 973 Personal Tokens can be generated in https://github.com/settings/tokens 974 This makes GitHub API have higher usage limits 975 """ 976 if filename is None: 977 return None 978 979 try: 980 with open(pathlib.Path(str(filename)).expanduser()) as token_file: 981 match = re.search('(?P<token>\\S+)', token_file.read(), 982 flags=re.MULTILINE) 983 if match is not None: 984 return match.group('token') 985 except IOError: 986 pass 987 988 return None 989 990 991@contextlib.contextmanager 992def smart_open(filename=None, *args, **kwargs): 993 if filename and (filename == '-' or filename == sys.stdout): 994 fh = sys.stdout 995 else: 996 fh = open(filename, *args, **kwargs) 997 998 try: 999 yield fh 1000 finally: 1001 if fh is not sys.stdout: 1002 fh.close() 1003 1004 1005def validate_file_for_writing(filepath): 1006 if (filepath is not None and 1007 filepath != sys.stdout and 1008 (not filepath.parent.exists() or 1009 not filepath.parent.is_dir())): 1010 return False 1011 1012 1013def main_entry(argv): 1014 parser = argparse.ArgumentParser( 1015 description='Generates Changelog from now until the specified data.\n' 1016 'generate_changelog.py -D changelog_2019_03 ' 1017 '-t ../repo_token -f -e 2019-04-01 2019-03-01''', 1018 formatter_class=argparse.RawDescriptionHelpFormatter) 1019 1020 parser.add_argument( 1021 'target_date', 1022 help='Specify when should stop generating. Accepts "YYYY-MM-DD" ISO ' 1023 '8601 Date format.', 1024 type=lambda d: datetime.combine(date.fromisoformat(d), dtime.min) 1025 ) 1026 1027 def convert_path(x): 1028 if x == '-': 1029 return sys.stdout 1030 else: 1031 return pathlib.Path(x).expanduser().resolve() 1032 1033 parser.add_argument( 1034 '-D', '--by-date', 1035 help='Indicates changes should be presented grouped by Date. Requires ' 1036 'a filename parameter, "-" for STDOUT', 1037 type=convert_path, 1038 default=None 1039 ) 1040 1041 parser.add_argument( 1042 '-B', '--by-build', 1043 help='Indicates changes should be presented grouped by Build. ' 1044 'Requires a filename parameter, "-" for STDOUT', 1045 type=convert_path, 1046 default=None 1047 ) 1048 1049 parser.add_argument( 1050 '-e', '--end-date', 1051 help='Specify when should start generating. Accepts "YYYY-MM-DD" ISO ' 1052 '8601 Date format.', 1053 type=lambda d: datetime.combine(date.fromisoformat(d), dtime.max), 1054 default=datetime.utcnow() 1055 ) 1056 1057 parser.add_argument( 1058 '-t', '--token-file', 1059 help='Specify where to read the Personal Token. Default ' 1060 '"~/.generate_changelog.token".', 1061 type=lambda x: pathlib.Path(x).expanduser().resolve(), 1062 default='~/.generate_changelog.token' 1063 ) 1064 1065 parser.add_argument( 1066 '-N', '--include-summary-none', 1067 action='store_true', 1068 help='Indicates if Pull Requests with Summary "None" should be ' 1069 'included in the output.', 1070 default=None 1071 ) 1072 1073 parser.add_argument( 1074 '-f', '--flatten-output', 1075 action='store_true', 1076 help='Output a flattened format with no headings.', 1077 default=None 1078 ) 1079 1080 parser.add_argument( 1081 '--verbose', 1082 action='store_true', 1083 help='Indicates the logging system to generate more information about ' 1084 'actions.', 1085 default=None 1086 ) 1087 1088 arguments = parser.parse_args(argv[1:]) 1089 1090 logging.basicConfig( 1091 level=logging.DEBUG if arguments.verbose else logging.INFO, 1092 format=' LOG | %(threadName)s | %(levelname)s | %(message)s') 1093 1094 log.debug(f'Commandline Arguments (+defaults): {arguments}') 1095 1096 if validate_file_for_writing(arguments.by_date): 1097 raise ValueError(f"Specified directory in --by-date doesn't exist: " 1098 f"{arguments.by_date.parent}") 1099 1100 if validate_file_for_writing(arguments.by_build): 1101 raise ValueError(f"Specified directory in --by-build doesn't exist: " 1102 f"{arguments.by_build.parent}") 1103 1104 personal_token = read_personal_token(arguments.token_file) 1105 if personal_token is None: 1106 log.warning("GitHub Token was not provided, API calls will have " 1107 "severely limited rates.") 1108 1109 if arguments.by_date is None and arguments.by_build is None: 1110 raise ValueError("Script should be called with either --by-date or " 1111 "--by-build arguments or both.") 1112 1113 main_output(arguments.by_date, arguments.by_build, arguments.target_date, 1114 arguments.end_date, personal_token, 1115 arguments.include_summary_none, arguments.flatten_output) 1116 1117 1118def get_github_api_data(pr_repo, commit_repo, target_dttm, end_dttm, 1119 personal_token): 1120 1121 def load_github_repos(): 1122 commit_api = CommitApi(CommitFactory(), personal_token) 1123 commit_repo.add_multiple( 1124 commit_api.get_commit_list(target_dttm, end_dttm)) 1125 1126 pr_api = PullRequestApi(CDDAPullRequestFactory(), personal_token) 1127 pr_repo.add_multiple( 1128 pr_api.get_pr_list(target_dttm, end_dttm, merged_only=True)) 1129 1130 github_thread = threading.Thread( 1131 target=exit_on_exception(load_github_repos)) 1132 github_thread.name = 'WORKER_GIT' 1133 github_thread.daemon = True 1134 github_thread.start() 1135 1136 return github_thread 1137 1138 1139def get_jenkins_api_data(build_repo): 1140 1141 def load_jenkins_repo(): 1142 jenkins_api = JenkinsApi(JenkinsBuildFactory()) 1143 build_repo.add_multiple(jenkins_api.get_build_list()) 1144 1145 jenkins_thread = threading.Thread( 1146 target=exit_on_exception(load_jenkins_repo)) 1147 jenkins_thread.name = 'WORKER_JEN' 1148 jenkins_thread.daemon = True 1149 jenkins_thread.start() 1150 1151 return jenkins_thread 1152 1153 1154def main_output(by_date, by_build, target_dttm, end_dttm, personal_token, 1155 include_summary_none, flatten): 1156 threads = [] 1157 1158 if by_build is not None: 1159 build_repo = JenkinsBuildRepository() 1160 threads.append(get_jenkins_api_data(build_repo)) 1161 1162 pr_repo = CDDAPullRequestRepository() 1163 commit_repo = CommitRepository() 1164 threads.append(get_github_api_data(pr_repo, commit_repo, target_dttm, 1165 end_dttm, personal_token)) 1166 1167 for thread in threads: 1168 thread.join() 1169 1170 if by_date is not None: 1171 with smart_open(by_date, 'w', encoding='utf8') as output_file: 1172 build_output_by_date(pr_repo, commit_repo, target_dttm, end_dttm, 1173 output_file, include_summary_none, flatten) 1174 1175 if by_build is not None: 1176 with smart_open(by_build, 'w', encoding='utf8') as output_file: 1177 build_output_by_build(build_repo, pr_repo, commit_repo, 1178 output_file, include_summary_none) 1179 1180 1181def build_output_by_date(pr_repo, commit_repo, target_dttm, end_dttm, 1182 output_file, include_summary_none, flatten): 1183 # group commits with no PR by date 1184 commits_with_no_pr = collections.defaultdict(list) 1185 for commit in commit_repo.traverse_commits_by_first_parent(): 1186 if not pr_repo.get_pr_by_merge_hash(commit.hash): 1187 commits_with_no_pr[commit.commit_date].append(commit) 1188 1189 # group PRs by date 1190 pr_with_summary = collections.defaultdict(list) 1191 pr_with_invalid_summary = collections.defaultdict(list) 1192 pr_with_summary_none = collections.defaultdict(list) 1193 for pr in pr_repo.get_merged_pr_list_by_date(end_dttm, target_dttm): 1194 if pr.has_valid_summary and pr.summ_type == SummaryType.NONE: 1195 pr_with_summary_none[pr.merge_date].append(pr) 1196 elif pr.has_valid_summary: 1197 pr_with_summary[pr.merge_date].append(pr) 1198 elif not pr.has_valid_summary: 1199 pr_with_invalid_summary[pr.merge_date].append(pr) 1200 1201 # build main changelog output 1202 offset_dates = ( 1203 end_dttm.date() - timedelta(days=x) for x in itertools.count()) 1204 for curr_date in offset_dates: 1205 if curr_date < target_dttm.date(): 1206 break 1207 if curr_date not in pr_with_summary: 1208 if curr_date not in commits_with_no_pr: 1209 continue 1210 1211 print(f"{curr_date}", file=output_file, end='\n\n') 1212 1213 def sort_by_type(pr): 1214 return pr.summ_type 1215 1216 sorted_pr_list_by_category = sorted(pr_with_summary[curr_date], 1217 key=sort_by_type) 1218 prs_by_type = itertools.groupby( 1219 sorted_pr_list_by_category, key=sort_by_type) 1220 for pr_type, pr_list_by_category in prs_by_type: 1221 if not flatten: 1222 print(f" {pr_type}", file=output_file) 1223 for pr in pr_list_by_category: 1224 if flatten: 1225 print(f"{pr_type} {pr.summ_desc}", file=output_file) 1226 else: 1227 print(f" * {pr.summ_desc} (by {pr.author} in " 1228 f"PR {pr.id})", file=output_file) 1229 print(file=output_file) 1230 1231 if curr_date in commits_with_no_pr: 1232 if not flatten: 1233 print(f" MISC. COMMITS", file=output_file) 1234 for commit in commits_with_no_pr[curr_date]: 1235 if not flatten: 1236 print(f" * {commit.message} (by {commit.author} in " 1237 f"Commit {commit.hash[:7]})", 1238 file=output_file) 1239 else: 1240 print(f"COMMIT {commit.message})", file=output_file) 1241 print(file=output_file) 1242 1243 is_included_summary_none = ( 1244 include_summary_none and curr_date in pr_with_summary_none) 1245 if curr_date in pr_with_invalid_summary or is_included_summary_none: 1246 if not flatten: 1247 print(f" MISC. PULL REQUESTS", file=output_file) 1248 for pr in pr_with_invalid_summary[curr_date]: 1249 if not flatten: 1250 print(f" * {pr.title} (by {pr.author} in " 1251 f"PR {pr.id})", file=output_file) 1252 else: 1253 print(f"INVALID_SUMMARY {pr.title})", file=output_file) 1254 if include_summary_none: 1255 for pr in pr_with_summary_none[curr_date]: 1256 if not flatten: 1257 print(f" * [MINOR] {pr.title} (by {pr.author} " 1258 f"in PR {pr.id})", file=output_file) 1259 else: 1260 print(f"MINOR {pr.title})", file=output_file) 1261 print(file=output_file) 1262 1263 print(file=output_file) 1264 1265 1266def build_output_by_build(build_repo, pr_repo, commit_repo, output_file, 1267 include_summary_none): 1268 # "ABORTED" builds have no "hash" and fucks up the logic here... but just 1269 # to be sure, ignore builds without hash and changes will be atributed to 1270 # next build availiable that does have a hash 1271 def has_hash(build): 1272 return build.last_hash is not None 1273 sorted_builds = build_repo.get_all_builds( 1274 filter_by=has_hash, sort_by=lambda x: -x.build_dttm.timestamp()) 1275 for build in sorted_builds: 1276 # we need the previous build a hash/date hash 1277 # to find commits / pull requests that got into the build 1278 prev_build = build_repo.get_previous_build(build.number, has_hash) 1279 if prev_build is None: 1280 break 1281 1282 try: 1283 commits = list(commit_repo.get_commit_range_by_hash( 1284 build.last_hash, prev_build.last_hash)) 1285 except MissingCommitException: 1286 # we obtained half of the build's commit with our GitHub API 1287 # request just avoid showing this build's partial data 1288 break 1289 1290 print(f'BUILD {build.number} / {build.build_dttm} UTC+0 / ' 1291 f'{build.last_hash[:7]}', file=output_file, end='\n\n') 1292 if build.last_hash == prev_build.last_hash: 1293 print(f' * No changes. Same code as BUILD {prev_build.number}.', 1294 file=output_file) 1295 # I could skip to next build here, but letting the go continue 1296 # could help spot bugs in the logic the code should not generate 1297 # any output lines for these Builds. 1298 #continue 1299 1300 # I can get PRs by matching Build Commits to PRs by Merge Hash 1301 # This is precise, but may fail to associate some Commits to PRs 1302 # leaving few "Summaries" out 1303 # pull_requests = (pr_repo.get_pr_by_merge_hash(c.hash) 1304 # for c in commits 1305 # if pr_repo.get_pr_by_merge_hash(c.hash)) 1306 # Another option is to find a proper Date range for the Build and find 1307 # PRs by date; but I found some issues here: 1308 # * Jenkins Build Date don't end up matching the correct PRs because 1309 # git fetch could be delayed like 15mins 1310 # * Using Build Commit Dates is better, but a few times it incorrectly 1311 # match the PR one build later 1312 # The worst ofender seem to be merges with message like "Merge 1313 # remote-tracking branch 'origin/pr/25005'" 1314 # build_commit = commit_repo.get_commit(build.last_hash) 1315 # prev_build_commit = commit_repo.get_commit(prev_build.last_hash) 1316 # pull_requests = pr_repo.get_merged_pr_list_by_date( 1317 # build_commit.commit_dttm + timedelta(seconds=2), 1318 # prev_build_commit.commit_dttm + timedelta(seconds=2)) 1319 1320 # I'll go with the safe method, this will show some COMMIT messages 1321 # instead of the proper Summary from the PR. 1322 pull_requests = ( 1323 pr_repo.get_pr_by_merge_hash(c.hash) for c in commits 1324 if pr_repo.get_pr_by_merge_hash(c.hash)) 1325 1326 commits_with_no_pr = [ 1327 c for c in commits if not pr_repo.get_pr_by_merge_hash(c.hash)] 1328 1329 pr_with_summary = list() 1330 pr_with_invalid_summary = list() 1331 pr_with_summary_none = list() 1332 for pr in pull_requests: 1333 if pr.has_valid_summary and pr.summ_type == SummaryType.NONE: 1334 pr_with_summary_none.append(pr) 1335 elif pr.has_valid_summary: 1336 pr_with_summary.append(pr) 1337 elif not pr.has_valid_summary: 1338 pr_with_invalid_summary.append(pr) 1339 1340 def sort_by_type(pr): 1341 return pr.summ_type 1342 sorted_pr_list_by_category = sorted(pr_with_summary, key=sort_by_type) 1343 prs_by_type = itertools.groupby( 1344 sorted_pr_list_by_category, key=sort_by_type) 1345 for pr_type, pr_list_by_category in prs_by_type: 1346 print(f" {pr_type}", file=output_file) 1347 for pr in pr_list_by_category: 1348 print(f" * {pr.summ_desc} (by {pr.author} in " 1349 f"PR {pr.id})", file=output_file) 1350 print(file=output_file) 1351 1352 if len(commits_with_no_pr) > 0: 1353 print(f" MISC. COMMITS", file=output_file) 1354 for commit in commits_with_no_pr: 1355 print(f" * {commit.message} (by {commit.author} in " 1356 f"Commit {commit.hash[:7]})", file=output_file) 1357 print(file=output_file) 1358 1359 is_included_summary_none = ( 1360 include_summary_none and len(pr_with_summary_none) > 0) 1361 if len(pr_with_invalid_summary) > 0 or is_included_summary_none: 1362 print(f" MISC. PULL REQUESTS", file=output_file) 1363 for pr in pr_with_invalid_summary: 1364 print(f" * {pr.title} (by {pr.author} in PR {pr.id})", 1365 file=output_file) 1366 if include_summary_none: 1367 for pr in pr_with_summary_none: 1368 print(f" * [MINOR] {pr.title} (by {pr.author} in " 1369 f"PR {pr.id})", file=output_file) 1370 print(file=output_file) 1371 1372 print(file=output_file) 1373 1374 1375if __name__ == '__main__': 1376 main_entry(sys.argv) 1377