1a8e1175bSopenharmony_ci#!/usr/bin/env python3
2a8e1175bSopenharmony_ci
3a8e1175bSopenharmony_ci"""Assemble Mbed TLS change log entries into the change log file.
4a8e1175bSopenharmony_ci
5a8e1175bSopenharmony_ciAdd changelog entries to the first level-2 section.
6a8e1175bSopenharmony_ciCreate a new level-2 section for unreleased changes if needed.
7a8e1175bSopenharmony_ciRemove the input files unless --keep-entries is specified.
8a8e1175bSopenharmony_ci
9a8e1175bSopenharmony_ciIn each level-3 section, entries are sorted in chronological order
10a8e1175bSopenharmony_ci(oldest first). From oldest to newest:
11a8e1175bSopenharmony_ci* Merged entry files are sorted according to their merge date (date of
12a8e1175bSopenharmony_ci  the merge commit that brought the commit that created the file into
13a8e1175bSopenharmony_ci  the target branch).
14a8e1175bSopenharmony_ci* Committed but unmerged entry files are sorted according to the date
15a8e1175bSopenharmony_ci  of the commit that adds them.
16a8e1175bSopenharmony_ci* Uncommitted entry files are sorted according to their modification time.
17a8e1175bSopenharmony_ci
18a8e1175bSopenharmony_ciYou must run this program from within a git working directory.
19a8e1175bSopenharmony_ci"""
20a8e1175bSopenharmony_ci
21a8e1175bSopenharmony_ci# Copyright The Mbed TLS Contributors
22a8e1175bSopenharmony_ci# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
23a8e1175bSopenharmony_ci
24a8e1175bSopenharmony_ciimport argparse
25a8e1175bSopenharmony_cifrom collections import OrderedDict, namedtuple
26a8e1175bSopenharmony_ciimport datetime
27a8e1175bSopenharmony_ciimport functools
28a8e1175bSopenharmony_ciimport glob
29a8e1175bSopenharmony_ciimport os
30a8e1175bSopenharmony_ciimport re
31a8e1175bSopenharmony_ciimport subprocess
32a8e1175bSopenharmony_ciimport sys
33a8e1175bSopenharmony_ci
34a8e1175bSopenharmony_ciclass InputFormatError(Exception):
35a8e1175bSopenharmony_ci    def __init__(self, filename, line_number, message, *args, **kwargs):
36a8e1175bSopenharmony_ci        message = '{}:{}: {}'.format(filename, line_number,
37a8e1175bSopenharmony_ci                                     message.format(*args, **kwargs))
38a8e1175bSopenharmony_ci        super().__init__(message)
39a8e1175bSopenharmony_ci
40a8e1175bSopenharmony_ciclass CategoryParseError(Exception):
41a8e1175bSopenharmony_ci    def __init__(self, line_offset, error_message):
42a8e1175bSopenharmony_ci        self.line_offset = line_offset
43a8e1175bSopenharmony_ci        self.error_message = error_message
44a8e1175bSopenharmony_ci        super().__init__('{}: {}'.format(line_offset, error_message))
45a8e1175bSopenharmony_ci
46a8e1175bSopenharmony_ciclass LostContent(Exception):
47a8e1175bSopenharmony_ci    def __init__(self, filename, line):
48a8e1175bSopenharmony_ci        message = ('Lost content from {}: "{}"'.format(filename, line))
49a8e1175bSopenharmony_ci        super().__init__(message)
50a8e1175bSopenharmony_ci
51a8e1175bSopenharmony_ciclass FilePathError(Exception):
52a8e1175bSopenharmony_ci    def __init__(self, filenames):
53a8e1175bSopenharmony_ci        message = ('Changelog filenames do not end with .txt: {}'.format(", ".join(filenames)))
54a8e1175bSopenharmony_ci        super().__init__(message)
55a8e1175bSopenharmony_ci
56a8e1175bSopenharmony_ci# The category names we use in the changelog.
57a8e1175bSopenharmony_ci# If you edit this, update ChangeLog.d/README.md.
58a8e1175bSopenharmony_ciSTANDARD_CATEGORIES = (
59a8e1175bSopenharmony_ci    'API changes',
60a8e1175bSopenharmony_ci    'Default behavior changes',
61a8e1175bSopenharmony_ci    'Requirement changes',
62a8e1175bSopenharmony_ci    'New deprecations',
63a8e1175bSopenharmony_ci    'Removals',
64a8e1175bSopenharmony_ci    'Features',
65a8e1175bSopenharmony_ci    'Security',
66a8e1175bSopenharmony_ci    'Bugfix',
67a8e1175bSopenharmony_ci    'Changes',
68a8e1175bSopenharmony_ci)
69a8e1175bSopenharmony_ci
70a8e1175bSopenharmony_ci# The maximum line length for an entry
71a8e1175bSopenharmony_ciMAX_LINE_LENGTH = 80
72a8e1175bSopenharmony_ci
73a8e1175bSopenharmony_ciCategoryContent = namedtuple('CategoryContent', [
74a8e1175bSopenharmony_ci    'name', 'title_line', # Title text and line number of the title
75a8e1175bSopenharmony_ci    'body', 'body_line', # Body text and starting line number of the body
76a8e1175bSopenharmony_ci])
77a8e1175bSopenharmony_ci
78a8e1175bSopenharmony_ciclass ChangelogFormat:
79a8e1175bSopenharmony_ci    """Virtual class documenting how to write a changelog format class."""
80a8e1175bSopenharmony_ci
81a8e1175bSopenharmony_ci    @classmethod
82a8e1175bSopenharmony_ci    def extract_top_version(cls, changelog_file_content):
83a8e1175bSopenharmony_ci        """Split out the top version section.
84a8e1175bSopenharmony_ci
85a8e1175bSopenharmony_ci        If the top version is already released, create a new top
86a8e1175bSopenharmony_ci        version section for an unreleased version.
87a8e1175bSopenharmony_ci
88a8e1175bSopenharmony_ci        Return ``(header, top_version_title, top_version_body, trailer)``
89a8e1175bSopenharmony_ci        where the "top version" is the existing top version section if it's
90a8e1175bSopenharmony_ci        for unreleased changes, and a newly created section otherwise.
91a8e1175bSopenharmony_ci        To assemble the changelog after modifying top_version_body,
92a8e1175bSopenharmony_ci        concatenate the four pieces.
93a8e1175bSopenharmony_ci        """
94a8e1175bSopenharmony_ci        raise NotImplementedError
95a8e1175bSopenharmony_ci
96a8e1175bSopenharmony_ci    @classmethod
97a8e1175bSopenharmony_ci    def version_title_text(cls, version_title):
98a8e1175bSopenharmony_ci        """Return the text of a formatted version section title."""
99a8e1175bSopenharmony_ci        raise NotImplementedError
100a8e1175bSopenharmony_ci
101a8e1175bSopenharmony_ci    @classmethod
102a8e1175bSopenharmony_ci    def split_categories(cls, version_body):
103a8e1175bSopenharmony_ci        """Split a changelog version section body into categories.
104a8e1175bSopenharmony_ci
105a8e1175bSopenharmony_ci        Return a list of `CategoryContent` the name is category title
106a8e1175bSopenharmony_ci        without any formatting.
107a8e1175bSopenharmony_ci        """
108a8e1175bSopenharmony_ci        raise NotImplementedError
109a8e1175bSopenharmony_ci
110a8e1175bSopenharmony_ci    @classmethod
111a8e1175bSopenharmony_ci    def format_category(cls, title, body):
112a8e1175bSopenharmony_ci        """Construct the text of a category section from its title and body."""
113a8e1175bSopenharmony_ci        raise NotImplementedError
114a8e1175bSopenharmony_ci
115a8e1175bSopenharmony_ciclass TextChangelogFormat(ChangelogFormat):
116a8e1175bSopenharmony_ci    """The traditional Mbed TLS changelog format."""
117a8e1175bSopenharmony_ci
118a8e1175bSopenharmony_ci    _unreleased_version_text = '= {} x.x.x branch released xxxx-xx-xx'
119a8e1175bSopenharmony_ci    @classmethod
120a8e1175bSopenharmony_ci    def is_released_version(cls, title):
121a8e1175bSopenharmony_ci        # Look for an incomplete release date
122a8e1175bSopenharmony_ci        return not re.search(r'[0-9x]{4}-[0-9x]{2}-[0-9x]?x', title)
123a8e1175bSopenharmony_ci
124a8e1175bSopenharmony_ci    _top_version_re = re.compile(r'(?:\A|\n)(=[^\n]*\n+)(.*?\n)(?:=|$)',
125a8e1175bSopenharmony_ci                                 re.DOTALL)
126a8e1175bSopenharmony_ci    _name_re = re.compile(r'=\s(.*)\s[0-9x]+\.', re.DOTALL)
127a8e1175bSopenharmony_ci    @classmethod
128a8e1175bSopenharmony_ci    def extract_top_version(cls, changelog_file_content):
129a8e1175bSopenharmony_ci        """A version section starts with a line starting with '='."""
130a8e1175bSopenharmony_ci        m = re.search(cls._top_version_re, changelog_file_content)
131a8e1175bSopenharmony_ci        top_version_start = m.start(1)
132a8e1175bSopenharmony_ci        top_version_end = m.end(2)
133a8e1175bSopenharmony_ci        top_version_title = m.group(1)
134a8e1175bSopenharmony_ci        top_version_body = m.group(2)
135a8e1175bSopenharmony_ci        name = re.match(cls._name_re, top_version_title).group(1)
136a8e1175bSopenharmony_ci        if cls.is_released_version(top_version_title):
137a8e1175bSopenharmony_ci            top_version_end = top_version_start
138a8e1175bSopenharmony_ci            top_version_title = cls._unreleased_version_text.format(name) + '\n\n'
139a8e1175bSopenharmony_ci            top_version_body = ''
140a8e1175bSopenharmony_ci        return (changelog_file_content[:top_version_start],
141a8e1175bSopenharmony_ci                top_version_title, top_version_body,
142a8e1175bSopenharmony_ci                changelog_file_content[top_version_end:])
143a8e1175bSopenharmony_ci
144a8e1175bSopenharmony_ci    @classmethod
145a8e1175bSopenharmony_ci    def version_title_text(cls, version_title):
146a8e1175bSopenharmony_ci        return re.sub(r'\n.*', version_title, re.DOTALL)
147a8e1175bSopenharmony_ci
148a8e1175bSopenharmony_ci    _category_title_re = re.compile(r'(^\w.*)\n+', re.MULTILINE)
149a8e1175bSopenharmony_ci    @classmethod
150a8e1175bSopenharmony_ci    def split_categories(cls, version_body):
151a8e1175bSopenharmony_ci        """A category title is a line with the title in column 0."""
152a8e1175bSopenharmony_ci        if not version_body:
153a8e1175bSopenharmony_ci            return []
154a8e1175bSopenharmony_ci        title_matches = list(re.finditer(cls._category_title_re, version_body))
155a8e1175bSopenharmony_ci        if not title_matches or title_matches[0].start() != 0:
156a8e1175bSopenharmony_ci            # There is junk before the first category.
157a8e1175bSopenharmony_ci            raise CategoryParseError(0, 'Junk found where category expected')
158a8e1175bSopenharmony_ci        title_starts = [m.start(1) for m in title_matches]
159a8e1175bSopenharmony_ci        body_starts = [m.end(0) for m in title_matches]
160a8e1175bSopenharmony_ci        body_ends = title_starts[1:] + [len(version_body)]
161a8e1175bSopenharmony_ci        bodies = [version_body[body_start:body_end].rstrip('\n') + '\n'
162a8e1175bSopenharmony_ci                  for (body_start, body_end) in zip(body_starts, body_ends)]
163a8e1175bSopenharmony_ci        title_lines = [version_body[:pos].count('\n') for pos in title_starts]
164a8e1175bSopenharmony_ci        body_lines = [version_body[:pos].count('\n') for pos in body_starts]
165a8e1175bSopenharmony_ci        return [CategoryContent(title_match.group(1), title_line,
166a8e1175bSopenharmony_ci                                body, body_line)
167a8e1175bSopenharmony_ci                for title_match, title_line, body, body_line
168a8e1175bSopenharmony_ci                in zip(title_matches, title_lines, bodies, body_lines)]
169a8e1175bSopenharmony_ci
170a8e1175bSopenharmony_ci    @classmethod
171a8e1175bSopenharmony_ci    def format_category(cls, title, body):
172a8e1175bSopenharmony_ci        # `split_categories` ensures that each body ends with a newline.
173a8e1175bSopenharmony_ci        # Make sure that there is additionally a blank line between categories.
174a8e1175bSopenharmony_ci        if not body.endswith('\n\n'):
175a8e1175bSopenharmony_ci            body += '\n'
176a8e1175bSopenharmony_ci        return title + '\n' + body
177a8e1175bSopenharmony_ci
178a8e1175bSopenharmony_ciclass ChangeLog:
179a8e1175bSopenharmony_ci    """An Mbed TLS changelog.
180a8e1175bSopenharmony_ci
181a8e1175bSopenharmony_ci    A changelog file consists of some header text followed by one or
182a8e1175bSopenharmony_ci    more version sections. The version sections are in reverse
183a8e1175bSopenharmony_ci    chronological order. Each version section consists of a title and a body.
184a8e1175bSopenharmony_ci
185a8e1175bSopenharmony_ci    The body of a version section consists of zero or more category
186a8e1175bSopenharmony_ci    subsections. Each category subsection consists of a title and a body.
187a8e1175bSopenharmony_ci
188a8e1175bSopenharmony_ci    A changelog entry file has the same format as the body of a version section.
189a8e1175bSopenharmony_ci
190a8e1175bSopenharmony_ci    A `ChangelogFormat` object defines the concrete syntax of the changelog.
191a8e1175bSopenharmony_ci    Entry files must have the same format as the changelog file.
192a8e1175bSopenharmony_ci    """
193a8e1175bSopenharmony_ci
194a8e1175bSopenharmony_ci    # Only accept dotted version numbers (e.g. "3.1", not "3").
195a8e1175bSopenharmony_ci    # Refuse ".x" in a version number where x is a letter: this indicates
196a8e1175bSopenharmony_ci    # a version that is not yet released. Something like "3.1a" is accepted.
197a8e1175bSopenharmony_ci    _version_number_re = re.compile(r'[0-9]+\.[0-9A-Za-z.]+')
198a8e1175bSopenharmony_ci    _incomplete_version_number_re = re.compile(r'.*\.[A-Za-z]')
199a8e1175bSopenharmony_ci    _only_url_re = re.compile(r'^\s*\w+://\S+\s*$')
200a8e1175bSopenharmony_ci    _has_url_re = re.compile(r'.*://.*')
201a8e1175bSopenharmony_ci
202a8e1175bSopenharmony_ci    def add_categories_from_text(self, filename, line_offset,
203a8e1175bSopenharmony_ci                                 text, allow_unknown_category):
204a8e1175bSopenharmony_ci        """Parse a version section or entry file."""
205a8e1175bSopenharmony_ci        try:
206a8e1175bSopenharmony_ci            categories = self.format.split_categories(text)
207a8e1175bSopenharmony_ci        except CategoryParseError as e:
208a8e1175bSopenharmony_ci            raise InputFormatError(filename, line_offset + e.line_offset,
209a8e1175bSopenharmony_ci                                   e.error_message)
210a8e1175bSopenharmony_ci        for category in categories:
211a8e1175bSopenharmony_ci            if not allow_unknown_category and \
212a8e1175bSopenharmony_ci               category.name not in self.categories:
213a8e1175bSopenharmony_ci                raise InputFormatError(filename,
214a8e1175bSopenharmony_ci                                       line_offset + category.title_line,
215a8e1175bSopenharmony_ci                                       'Unknown category: "{}"',
216a8e1175bSopenharmony_ci                                       category.name)
217a8e1175bSopenharmony_ci
218a8e1175bSopenharmony_ci            body_split = category.body.splitlines()
219a8e1175bSopenharmony_ci
220a8e1175bSopenharmony_ci            for line_number, line in enumerate(body_split, 1):
221a8e1175bSopenharmony_ci                if not self._only_url_re.match(line) and \
222a8e1175bSopenharmony_ci                   len(line) > MAX_LINE_LENGTH:
223a8e1175bSopenharmony_ci                    long_url_msg = '. URL exceeding length limit must be alone in its line.' \
224a8e1175bSopenharmony_ci                        if self._has_url_re.match(line) else ""
225a8e1175bSopenharmony_ci                    raise InputFormatError(filename,
226a8e1175bSopenharmony_ci                                           category.body_line + line_number,
227a8e1175bSopenharmony_ci                                           'Line is longer than allowed: '
228a8e1175bSopenharmony_ci                                           'Length {} (Max {}){}',
229a8e1175bSopenharmony_ci                                           len(line), MAX_LINE_LENGTH,
230a8e1175bSopenharmony_ci                                           long_url_msg)
231a8e1175bSopenharmony_ci
232a8e1175bSopenharmony_ci            self.categories[category.name] += category.body
233a8e1175bSopenharmony_ci
234a8e1175bSopenharmony_ci    def __init__(self, input_stream, changelog_format):
235a8e1175bSopenharmony_ci        """Create a changelog object.
236a8e1175bSopenharmony_ci
237a8e1175bSopenharmony_ci        Populate the changelog object from the content of the file
238a8e1175bSopenharmony_ci        input_stream.
239a8e1175bSopenharmony_ci        """
240a8e1175bSopenharmony_ci        self.format = changelog_format
241a8e1175bSopenharmony_ci        whole_file = input_stream.read()
242a8e1175bSopenharmony_ci        (self.header,
243a8e1175bSopenharmony_ci         self.top_version_title, top_version_body,
244a8e1175bSopenharmony_ci         self.trailer) = self.format.extract_top_version(whole_file)
245a8e1175bSopenharmony_ci        # Split the top version section into categories.
246a8e1175bSopenharmony_ci        self.categories = OrderedDict()
247a8e1175bSopenharmony_ci        for category in STANDARD_CATEGORIES:
248a8e1175bSopenharmony_ci            self.categories[category] = ''
249a8e1175bSopenharmony_ci        offset = (self.header + self.top_version_title).count('\n') + 1
250a8e1175bSopenharmony_ci
251a8e1175bSopenharmony_ci        self.add_categories_from_text(input_stream.name, offset,
252a8e1175bSopenharmony_ci                                      top_version_body, True)
253a8e1175bSopenharmony_ci
254a8e1175bSopenharmony_ci    def add_file(self, input_stream):
255a8e1175bSopenharmony_ci        """Add changelog entries from a file.
256a8e1175bSopenharmony_ci        """
257a8e1175bSopenharmony_ci        self.add_categories_from_text(input_stream.name, 1,
258a8e1175bSopenharmony_ci                                      input_stream.read(), False)
259a8e1175bSopenharmony_ci
260a8e1175bSopenharmony_ci    def write(self, filename):
261a8e1175bSopenharmony_ci        """Write the changelog to the specified file.
262a8e1175bSopenharmony_ci        """
263a8e1175bSopenharmony_ci        with open(filename, 'w', encoding='utf-8') as out:
264a8e1175bSopenharmony_ci            out.write(self.header)
265a8e1175bSopenharmony_ci            out.write(self.top_version_title)
266a8e1175bSopenharmony_ci            for title, body in self.categories.items():
267a8e1175bSopenharmony_ci                if not body:
268a8e1175bSopenharmony_ci                    continue
269a8e1175bSopenharmony_ci                out.write(self.format.format_category(title, body))
270a8e1175bSopenharmony_ci            out.write(self.trailer)
271a8e1175bSopenharmony_ci
272a8e1175bSopenharmony_ci
273a8e1175bSopenharmony_ci@functools.total_ordering
274a8e1175bSopenharmony_ciclass EntryFileSortKey:
275a8e1175bSopenharmony_ci    """This classes defines an ordering on changelog entry files: older < newer.
276a8e1175bSopenharmony_ci
277a8e1175bSopenharmony_ci    * Merged entry files are sorted according to their merge date (date of
278a8e1175bSopenharmony_ci      the merge commit that brought the commit that created the file into
279a8e1175bSopenharmony_ci      the target branch).
280a8e1175bSopenharmony_ci    * Committed but unmerged entry files are sorted according to the date
281a8e1175bSopenharmony_ci      of the commit that adds them.
282a8e1175bSopenharmony_ci    * Uncommitted entry files are sorted according to their modification time.
283a8e1175bSopenharmony_ci
284a8e1175bSopenharmony_ci    This class assumes that the file is in a git working directory with
285a8e1175bSopenharmony_ci    the target branch checked out.
286a8e1175bSopenharmony_ci    """
287a8e1175bSopenharmony_ci
288a8e1175bSopenharmony_ci    # Categories of files. A lower number is considered older.
289a8e1175bSopenharmony_ci    MERGED = 0
290a8e1175bSopenharmony_ci    COMMITTED = 1
291a8e1175bSopenharmony_ci    LOCAL = 2
292a8e1175bSopenharmony_ci
293a8e1175bSopenharmony_ci    @staticmethod
294a8e1175bSopenharmony_ci    def creation_hash(filename):
295a8e1175bSopenharmony_ci        """Return the git commit id at which the given file was created.
296a8e1175bSopenharmony_ci
297a8e1175bSopenharmony_ci        Return None if the file was never checked into git.
298a8e1175bSopenharmony_ci        """
299a8e1175bSopenharmony_ci        hashes = subprocess.check_output(['git', 'log', '--format=%H',
300a8e1175bSopenharmony_ci                                          '--follow',
301a8e1175bSopenharmony_ci                                          '--', filename])
302a8e1175bSopenharmony_ci        m = re.search('(.+)$', hashes.decode('ascii'))
303a8e1175bSopenharmony_ci        if not m:
304a8e1175bSopenharmony_ci            # The git output is empty. This means that the file was
305a8e1175bSopenharmony_ci            # never checked in.
306a8e1175bSopenharmony_ci            return None
307a8e1175bSopenharmony_ci        # The last commit in the log is the oldest one, which is when the
308a8e1175bSopenharmony_ci        # file was created.
309a8e1175bSopenharmony_ci        return m.group(0)
310a8e1175bSopenharmony_ci
311a8e1175bSopenharmony_ci    @staticmethod
312a8e1175bSopenharmony_ci    def list_merges(some_hash, target, *options):
313a8e1175bSopenharmony_ci        """List merge commits from some_hash to target.
314a8e1175bSopenharmony_ci
315a8e1175bSopenharmony_ci        Pass options to git to select which commits are included.
316a8e1175bSopenharmony_ci        """
317a8e1175bSopenharmony_ci        text = subprocess.check_output(['git', 'rev-list',
318a8e1175bSopenharmony_ci                                        '--merges', *options,
319a8e1175bSopenharmony_ci                                        '..'.join([some_hash, target])])
320a8e1175bSopenharmony_ci        return text.decode('ascii').rstrip('\n').split('\n')
321a8e1175bSopenharmony_ci
322a8e1175bSopenharmony_ci    @classmethod
323a8e1175bSopenharmony_ci    def merge_hash(cls, some_hash):
324a8e1175bSopenharmony_ci        """Return the git commit id at which the given commit was merged.
325a8e1175bSopenharmony_ci
326a8e1175bSopenharmony_ci        Return None if the given commit was never merged.
327a8e1175bSopenharmony_ci        """
328a8e1175bSopenharmony_ci        target = 'HEAD'
329a8e1175bSopenharmony_ci        # List the merges from some_hash to the target in two ways.
330a8e1175bSopenharmony_ci        # The ancestry list is the ones that are both descendants of
331a8e1175bSopenharmony_ci        # some_hash and ancestors of the target.
332a8e1175bSopenharmony_ci        ancestry = frozenset(cls.list_merges(some_hash, target,
333a8e1175bSopenharmony_ci                                             '--ancestry-path'))
334a8e1175bSopenharmony_ci        # The first_parents list only contains merges that are directly
335a8e1175bSopenharmony_ci        # on the target branch. We want it in reverse order (oldest first).
336a8e1175bSopenharmony_ci        first_parents = cls.list_merges(some_hash, target,
337a8e1175bSopenharmony_ci                                        '--first-parent', '--reverse')
338a8e1175bSopenharmony_ci        # Look for the oldest merge commit that's both on the direct path
339a8e1175bSopenharmony_ci        # and directly on the target branch. That's the place where some_hash
340a8e1175bSopenharmony_ci        # was merged on the target branch. See
341a8e1175bSopenharmony_ci        # https://stackoverflow.com/questions/8475448/find-merge-commit-which-include-a-specific-commit
342a8e1175bSopenharmony_ci        for commit in first_parents:
343a8e1175bSopenharmony_ci            if commit in ancestry:
344a8e1175bSopenharmony_ci                return commit
345a8e1175bSopenharmony_ci        return None
346a8e1175bSopenharmony_ci
347a8e1175bSopenharmony_ci    @staticmethod
348a8e1175bSopenharmony_ci    def commit_timestamp(commit_id):
349a8e1175bSopenharmony_ci        """Return the timestamp of the given commit."""
350a8e1175bSopenharmony_ci        text = subprocess.check_output(['git', 'show', '-s',
351a8e1175bSopenharmony_ci                                        '--format=%ct',
352a8e1175bSopenharmony_ci                                        commit_id])
353a8e1175bSopenharmony_ci        return datetime.datetime.utcfromtimestamp(int(text))
354a8e1175bSopenharmony_ci
355a8e1175bSopenharmony_ci    @staticmethod
356a8e1175bSopenharmony_ci    def file_timestamp(filename):
357a8e1175bSopenharmony_ci        """Return the modification timestamp of the given file."""
358a8e1175bSopenharmony_ci        mtime = os.stat(filename).st_mtime
359a8e1175bSopenharmony_ci        return datetime.datetime.fromtimestamp(mtime)
360a8e1175bSopenharmony_ci
361a8e1175bSopenharmony_ci    def __init__(self, filename):
362a8e1175bSopenharmony_ci        """Determine position of the file in the changelog entry order.
363a8e1175bSopenharmony_ci
364a8e1175bSopenharmony_ci        This constructor returns an object that can be used with comparison
365a8e1175bSopenharmony_ci        operators, with `sort` and `sorted`, etc. Older entries are sorted
366a8e1175bSopenharmony_ci        before newer entries.
367a8e1175bSopenharmony_ci        """
368a8e1175bSopenharmony_ci        self.filename = filename
369a8e1175bSopenharmony_ci        creation_hash = self.creation_hash(filename)
370a8e1175bSopenharmony_ci        if not creation_hash:
371a8e1175bSopenharmony_ci            self.category = self.LOCAL
372a8e1175bSopenharmony_ci            self.datetime = self.file_timestamp(filename)
373a8e1175bSopenharmony_ci            return
374a8e1175bSopenharmony_ci        merge_hash = self.merge_hash(creation_hash)
375a8e1175bSopenharmony_ci        if not merge_hash:
376a8e1175bSopenharmony_ci            self.category = self.COMMITTED
377a8e1175bSopenharmony_ci            self.datetime = self.commit_timestamp(creation_hash)
378a8e1175bSopenharmony_ci            return
379a8e1175bSopenharmony_ci        self.category = self.MERGED
380a8e1175bSopenharmony_ci        self.datetime = self.commit_timestamp(merge_hash)
381a8e1175bSopenharmony_ci
382a8e1175bSopenharmony_ci    def sort_key(self):
383a8e1175bSopenharmony_ci        """"Return a concrete sort key for this entry file sort key object.
384a8e1175bSopenharmony_ci
385a8e1175bSopenharmony_ci        ``ts1 < ts2`` is implemented as ``ts1.sort_key() < ts2.sort_key()``.
386a8e1175bSopenharmony_ci        """
387a8e1175bSopenharmony_ci        return (self.category, self.datetime, self.filename)
388a8e1175bSopenharmony_ci
389a8e1175bSopenharmony_ci    def __eq__(self, other):
390a8e1175bSopenharmony_ci        return self.sort_key() == other.sort_key()
391a8e1175bSopenharmony_ci
392a8e1175bSopenharmony_ci    def __lt__(self, other):
393a8e1175bSopenharmony_ci        return self.sort_key() < other.sort_key()
394a8e1175bSopenharmony_ci
395a8e1175bSopenharmony_ci
396a8e1175bSopenharmony_cidef check_output(generated_output_file, main_input_file, merged_files):
397a8e1175bSopenharmony_ci    """Make sanity checks on the generated output.
398a8e1175bSopenharmony_ci
399a8e1175bSopenharmony_ci    The intent of these sanity checks is to have reasonable confidence
400a8e1175bSopenharmony_ci    that no content has been lost.
401a8e1175bSopenharmony_ci
402a8e1175bSopenharmony_ci    The sanity check is that every line that is present in an input file
403a8e1175bSopenharmony_ci    is also present in an output file. This is not perfect but good enough
404a8e1175bSopenharmony_ci    for now.
405a8e1175bSopenharmony_ci    """
406a8e1175bSopenharmony_ci    with open(generated_output_file, 'r', encoding='utf-8') as fd:
407a8e1175bSopenharmony_ci        generated_output = set(fd)
408a8e1175bSopenharmony_ci        for line in open(main_input_file, 'r', encoding='utf-8'):
409a8e1175bSopenharmony_ci            if line not in generated_output:
410a8e1175bSopenharmony_ci                raise LostContent('original file', line)
411a8e1175bSopenharmony_ci        for merged_file in merged_files:
412a8e1175bSopenharmony_ci            for line in open(merged_file, 'r', encoding='utf-8'):
413a8e1175bSopenharmony_ci                if line not in generated_output:
414a8e1175bSopenharmony_ci                    raise LostContent(merged_file, line)
415a8e1175bSopenharmony_ci
416a8e1175bSopenharmony_cidef finish_output(changelog, output_file, input_file, merged_files):
417a8e1175bSopenharmony_ci    """Write the changelog to the output file.
418a8e1175bSopenharmony_ci
419a8e1175bSopenharmony_ci    The input file and the list of merged files are used only for sanity
420a8e1175bSopenharmony_ci    checks on the output.
421a8e1175bSopenharmony_ci    """
422a8e1175bSopenharmony_ci    if os.path.exists(output_file) and not os.path.isfile(output_file):
423a8e1175bSopenharmony_ci        # The output is a non-regular file (e.g. pipe). Write to it directly.
424a8e1175bSopenharmony_ci        output_temp = output_file
425a8e1175bSopenharmony_ci    else:
426a8e1175bSopenharmony_ci        # The output is a regular file. Write to a temporary file,
427a8e1175bSopenharmony_ci        # then move it into place atomically.
428a8e1175bSopenharmony_ci        output_temp = output_file + '.tmp'
429a8e1175bSopenharmony_ci    changelog.write(output_temp)
430a8e1175bSopenharmony_ci    check_output(output_temp, input_file, merged_files)
431a8e1175bSopenharmony_ci    if output_temp != output_file:
432a8e1175bSopenharmony_ci        os.rename(output_temp, output_file)
433a8e1175bSopenharmony_ci
434a8e1175bSopenharmony_cidef remove_merged_entries(files_to_remove):
435a8e1175bSopenharmony_ci    for filename in files_to_remove:
436a8e1175bSopenharmony_ci        os.remove(filename)
437a8e1175bSopenharmony_ci
438a8e1175bSopenharmony_cidef list_files_to_merge(options):
439a8e1175bSopenharmony_ci    """List the entry files to merge, oldest first.
440a8e1175bSopenharmony_ci
441a8e1175bSopenharmony_ci    "Oldest" is defined by `EntryFileSortKey`.
442a8e1175bSopenharmony_ci
443a8e1175bSopenharmony_ci    Also check for required .txt extension
444a8e1175bSopenharmony_ci    """
445a8e1175bSopenharmony_ci    files_to_merge = glob.glob(os.path.join(options.dir, '*'))
446a8e1175bSopenharmony_ci
447a8e1175bSopenharmony_ci    # Ignore 00README.md
448a8e1175bSopenharmony_ci    readme = os.path.join(options.dir, "00README.md")
449a8e1175bSopenharmony_ci    if readme in files_to_merge:
450a8e1175bSopenharmony_ci        files_to_merge.remove(readme)
451a8e1175bSopenharmony_ci
452a8e1175bSopenharmony_ci    # Identify files without the required .txt extension
453a8e1175bSopenharmony_ci    bad_files = [x for x in files_to_merge if not x.endswith(".txt")]
454a8e1175bSopenharmony_ci    if bad_files:
455a8e1175bSopenharmony_ci        raise FilePathError(bad_files)
456a8e1175bSopenharmony_ci
457a8e1175bSopenharmony_ci    files_to_merge.sort(key=EntryFileSortKey)
458a8e1175bSopenharmony_ci    return files_to_merge
459a8e1175bSopenharmony_ci
460a8e1175bSopenharmony_cidef merge_entries(options):
461a8e1175bSopenharmony_ci    """Merge changelog entries into the changelog file.
462a8e1175bSopenharmony_ci
463a8e1175bSopenharmony_ci    Read the changelog file from options.input.
464a8e1175bSopenharmony_ci    Check that all entries have a .txt extension
465a8e1175bSopenharmony_ci    Read entries to merge from the directory options.dir.
466a8e1175bSopenharmony_ci    Write the new changelog to options.output.
467a8e1175bSopenharmony_ci    Remove the merged entries if options.keep_entries is false.
468a8e1175bSopenharmony_ci    """
469a8e1175bSopenharmony_ci    with open(options.input, 'r', encoding='utf-8') as input_file:
470a8e1175bSopenharmony_ci        changelog = ChangeLog(input_file, TextChangelogFormat)
471a8e1175bSopenharmony_ci    files_to_merge = list_files_to_merge(options)
472a8e1175bSopenharmony_ci    if not files_to_merge:
473a8e1175bSopenharmony_ci        sys.stderr.write('There are no pending changelog entries.\n')
474a8e1175bSopenharmony_ci        return
475a8e1175bSopenharmony_ci    for filename in files_to_merge:
476a8e1175bSopenharmony_ci        with open(filename, 'r', encoding='utf-8') as input_file:
477a8e1175bSopenharmony_ci            changelog.add_file(input_file)
478a8e1175bSopenharmony_ci    finish_output(changelog, options.output, options.input, files_to_merge)
479a8e1175bSopenharmony_ci    if not options.keep_entries:
480a8e1175bSopenharmony_ci        remove_merged_entries(files_to_merge)
481a8e1175bSopenharmony_ci
482a8e1175bSopenharmony_cidef show_file_timestamps(options):
483a8e1175bSopenharmony_ci    """List the files to merge and their timestamp.
484a8e1175bSopenharmony_ci
485a8e1175bSopenharmony_ci    This is only intended for debugging purposes.
486a8e1175bSopenharmony_ci    """
487a8e1175bSopenharmony_ci    files = list_files_to_merge(options)
488a8e1175bSopenharmony_ci    for filename in files:
489a8e1175bSopenharmony_ci        ts = EntryFileSortKey(filename)
490a8e1175bSopenharmony_ci        print(ts.category, ts.datetime, filename)
491a8e1175bSopenharmony_ci
492a8e1175bSopenharmony_cidef set_defaults(options):
493a8e1175bSopenharmony_ci    """Add default values for missing options."""
494a8e1175bSopenharmony_ci    output_file = getattr(options, 'output', None)
495a8e1175bSopenharmony_ci    if output_file is None:
496a8e1175bSopenharmony_ci        options.output = options.input
497a8e1175bSopenharmony_ci    if getattr(options, 'keep_entries', None) is None:
498a8e1175bSopenharmony_ci        options.keep_entries = (output_file is not None)
499a8e1175bSopenharmony_ci
500a8e1175bSopenharmony_cidef main():
501a8e1175bSopenharmony_ci    """Command line entry point."""
502a8e1175bSopenharmony_ci    parser = argparse.ArgumentParser(description=__doc__)
503a8e1175bSopenharmony_ci    parser.add_argument('--dir', '-d', metavar='DIR',
504a8e1175bSopenharmony_ci                        default='ChangeLog.d',
505a8e1175bSopenharmony_ci                        help='Directory to read entries from'
506a8e1175bSopenharmony_ci                             ' (default: ChangeLog.d)')
507a8e1175bSopenharmony_ci    parser.add_argument('--input', '-i', metavar='FILE',
508a8e1175bSopenharmony_ci                        default='ChangeLog',
509a8e1175bSopenharmony_ci                        help='Existing changelog file to read from and augment'
510a8e1175bSopenharmony_ci                             ' (default: ChangeLog)')
511a8e1175bSopenharmony_ci    parser.add_argument('--keep-entries',
512a8e1175bSopenharmony_ci                        action='store_true', dest='keep_entries', default=None,
513a8e1175bSopenharmony_ci                        help='Keep the files containing entries'
514a8e1175bSopenharmony_ci                             ' (default: remove them if --output/-o is not specified)')
515a8e1175bSopenharmony_ci    parser.add_argument('--no-keep-entries',
516a8e1175bSopenharmony_ci                        action='store_false', dest='keep_entries',
517a8e1175bSopenharmony_ci                        help='Remove the files containing entries after they are merged'
518a8e1175bSopenharmony_ci                             ' (default: remove them if --output/-o is not specified)')
519a8e1175bSopenharmony_ci    parser.add_argument('--output', '-o', metavar='FILE',
520a8e1175bSopenharmony_ci                        help='Output changelog file'
521a8e1175bSopenharmony_ci                             ' (default: overwrite the input)')
522a8e1175bSopenharmony_ci    parser.add_argument('--list-files-only',
523a8e1175bSopenharmony_ci                        action='store_true',
524a8e1175bSopenharmony_ci                        help=('Only list the files that would be processed '
525a8e1175bSopenharmony_ci                              '(with some debugging information)'))
526a8e1175bSopenharmony_ci    options = parser.parse_args()
527a8e1175bSopenharmony_ci    set_defaults(options)
528a8e1175bSopenharmony_ci    if options.list_files_only:
529a8e1175bSopenharmony_ci        show_file_timestamps(options)
530a8e1175bSopenharmony_ci        return
531a8e1175bSopenharmony_ci    merge_entries(options)
532a8e1175bSopenharmony_ci
533a8e1175bSopenharmony_ciif __name__ == '__main__':
534a8e1175bSopenharmony_ci    main()
535