Merge branch 'stable' into devel
[tails.git] / bin / generate-changelog
blobae251b1c15334bf471daa18f39f67616e391a278
1 #! /usr/bin/python3
3 # Documentation: https://tails.net/contribute/working_together/GitLab/#api
5 import datetime
6 import email.utils
7 import functools
8 import gitlab
9 import jinja2
10 import logging
11 import os
12 import pprint
13 import re
15 from pathlib import Path
17 PYTHON_GITLAB_CONFIG_FILE = os.getenv('PYTHON_GITLAB_CONFIG_FILE',
18                                       default=Path.home() /
19                                       '.python-gitlab.cfg')
21 PYTHON_GITLAB_NAME = os.getenv('GITLAB_NAME', default='Tails')
23 GROUP_NAME = 'tails'
25 # Only changes in these projects are considered
26 PROJECTS = [
27     GROUP_NAME + '/' + project for project in [
28         'chutney',
29         'tails',
30         'workarounds',
31     ]
34 # Merge requests that modify only files whose path match one of IGNORED_PATHS
35 # are ignored.
36 # Patterns will be passed to re.fullmatch().
37 IGNORED_PATHS = [
38     r'po/.*',
39     r'wiki/.*',
42 LOG_FORMAT = "%(asctime)-15s %(levelname)s %(message)s"
43 log = logging.getLogger()
46 class GitLabWrapper(gitlab.Gitlab):
47     @functools.lru_cache(maxsize=None)
48     def project(self, project_id):
49         return self.projects.get(project_id)
51     @functools.lru_cache(maxsize=None)
52     def project_path_with_namespace(self, project_id):
53         return self.project(project_id).path_with_namespace
56 class ChangelogGenerator(object):
57     def __init__(self, gl, group, version: str):
58         self.gl = gl
59         self.group = group
60         self.version = version
62     def merge_requests(self, milestone) -> list:
63         mrs = []
65         gl_mrs = [
66             mr for mr in milestone.merge_requests(all=True)
67             if mr.state == 'merged' \
68             and self.gl.project_path_with_namespace(
69                 mr.target_project_id) in PROJECTS
70         ]
72         for mr in gl_mrs:
73             project = self.gl.project(mr.target_project_id)
74             mr = project.mergerequests.get(mr.iid)
75             if ignore_merge_request(mr):
76                 continue
77             # yapf: disable
78             mrs.append({
79                 "ref": mr.references['full'],
80                 "title": mr.title,
81                 "web_url": mr.web_url,
82                 "closes_issues": [
83                     {
84                         "ref": project.issues.get(issue.iid).references['full'],
85                         "title": issue.title,
86                     }
87                     for issue in mr.closes_issues()
88                 ],
89                 "commit_messages": [
90                     commit.title for commit in mr.commits()
91                     # Ignore merge commits
92                     if len(project.commits.get(commit.id).parent_ids) == 1
93                 ],
94             })
95             # yapf: enable
97         return mrs
99     def changes(self) -> dict:
100         milestone_title = "Tails_" + self.version
101         milestone = [
102             m for m in self.group.milestones.list(search=milestone_title)
103             # Disambiguate between milestones whose names share a common prefix
104             if m.title == milestone_title
105         ][0]
106         assert isinstance(milestone, gitlab.v4.objects.GroupMilestone)
108         return {
109             "merge_requests": self.merge_requests(milestone),
110             "issues": {},  # Let's see if we really need this; probably not.
111         }
114 def ignore_merge_request(mr) -> bool:
115     for change in mr.changes()['changes']:
116         for path in [change['old_path'], change['new_path']]:
117             if all([
118                     re.fullmatch(ignored_path, path) is None
119                     for ignored_path in IGNORED_PATHS
120             ]):
121                 log.debug("Returning false")
122                 return False
123     log.debug("Returning true")
124     return True
127 def changelog_entry(version: str, date: datetime, changes: dict):
128     jinja2_env = jinja2.Environment(  # nosec jinja2_autoescape_false
129         loader=jinja2.FileSystemLoader('config/release_management/templates'),
130         trim_blocks=True,
131         lstrip_blocks=True,
132     )
134     return jinja2_env.get_template('changelog.jinja2').render(
135         merge_requests=changes['merge_requests'],
136         issues=changes['issues'],
137         date=email.utils.format_datetime(date),
138         version=version,
139     )
142 if __name__ == '__main__':
143     import argparse
144     parser = argparse.ArgumentParser()
145     parser.add_argument('--version', required=True)
146     parser.add_argument("--debug", action="store_true", help="debug output")
147     args = parser.parse_args()
149     if args.debug:
150         logging.basicConfig(level=logging.DEBUG, format=LOG_FORMAT)
151     else:
152         logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
154     gl = GitLabWrapper.from_config(PYTHON_GITLAB_NAME,
155                                    config_files=[PYTHON_GITLAB_CONFIG_FILE])
156     gl.auth()
158     group = gl.groups.list(search=GROUP_NAME)[0]
159     assert isinstance(group, gitlab.v4.objects.Group)
161     changes = ChangelogGenerator(gl, group, args.version).changes()
163     log.debug(pprint.PrettyPrinter().pformat(changes))
165     print(
166         changelog_entry(version=args.version,
167                         date=datetime.datetime.now(datetime.timezone.utc),
168                         changes=changes))