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