Point submodule to the version with torbrowser-launcher!9 merged
[tails.git] / bin / generate-changelog
blob97babe9054f8da79cbf73840edce0064ff8dcb83
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(
18     "PYTHON_GITLAB_CONFIG_FILE", default=str(Path.home() / ".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
28     for project in [
29         "chutney",
30         "tails",
31         "workarounds",
32     ]
35 # Merge requests that modify only files whose path match one of IGNORED_PATHS
36 # are ignored.
37 # Patterns will be passed to re.fullmatch().
38 IGNORED_PATHS = [
39     r"po/.*",
40     r"wiki/.*",
43 LOG_FORMAT = "%(asctime)-15s %(levelname)s %(message)s"
44 log = logging.getLogger()
47 class GitLabWrapper(gitlab.Gitlab):
48     @functools.cache  # noqa:B019
49     def project(self, project_id):
50         return self.projects.get(project_id)
52     @functools.cache  # noqa:B019
53     def project_path_with_namespace(self, project_id):
54         return self.project(project_id).path_with_namespace
57 class ChangelogGenerator:
58     def __init__(self, gl, group, version: str):
59         self.gl = gl
60         self.group = group
61         self.version = version
63     def merge_requests(self, milestone) -> list:
64         mrs = []
66         gl_mrs = [
67             mr
68             for mr in milestone.merge_requests(all=True)
69             if mr.state == "merged"
70             and self.gl.project_path_with_namespace(mr.target_project_id) in PROJECTS
71         ]
73         for gl_mr in gl_mrs:
74             project = self.gl.project(gl_mr.target_project_id)
75             mr = project.mergerequests.get(gl_mr.iid)
76             if ignore_merge_request(mr):
77                 continue
78             mrs.append(
79                 {
80                     "ref": mr.references["full"],
81                     "title": mr.title,
82                     "web_url": mr.web_url,
83                     "closes_issues": [
84                         {
85                             "ref": project.issues.get(issue.iid).references["full"],
86                             "title": issue.title,
87                         }
88                         for issue in mr.closes_issues()
89                     ],
90                     "commit_messages": [
91                         commit.title
92                         for commit in mr.commits()
93                         # Ignore merge commits
94                         if len(project.commits.get(commit.id).parent_ids) == 1
95                     ],
96                 }
97             )
99         return mrs
101     def changes(self) -> dict:
102         milestone_title = "Tails_" + self.version
103         milestones = [
104             m
105             for m in self.group.milestones.list(search=milestone_title)
106             # Disambiguate between milestones whose names share a common prefix
107             if m.title == milestone_title
108         ]
109         if len(milestones) != 1:
110             raise ValueError(f"Need exactly 1 milestone called '{milestone_title}'")
111         milestone = milestones[0]
112         assert isinstance(milestone, gitlab.v4.objects.GroupMilestone)
114         return {
115             "merge_requests": self.merge_requests(milestone),
116             "issues": {},  # Let's see if we really need this; probably not.
117         }
120 def ignore_merge_request(mr) -> bool:
121     for change in mr.changes()["changes"]:
122         for path in [change["old_path"], change["new_path"]]:
123             if all(
124                 [
125                     re.fullmatch(ignored_path, path) is None
126                     for ignored_path in IGNORED_PATHS
127                 ]
128             ):
129                 log.debug("Returning false")
130                 return False
131     log.debug("Returning true")
132     return True
135 def changelog_entry(version: str, date: datetime, changes: dict):
136     jinja2_env = jinja2.Environment(  # noqa:S701
137         loader=jinja2.FileSystemLoader("config/release_management/templates"),
138         trim_blocks=True,
139         lstrip_blocks=True,
140     )
142     return jinja2_env.get_template("changelog.jinja2").render(
143         merge_requests=changes["merge_requests"],
144         issues=changes["issues"],
145         date=email.utils.format_datetime(date),
146         version=version,
147     )
150 if __name__ == "__main__":
151     import argparse
153     parser = argparse.ArgumentParser()
154     parser.add_argument("--version", required=True)
155     parser.add_argument("--debug", action="store_true", help="debug output")
156     args = parser.parse_args()
158     if args.debug:
159         logging.basicConfig(level=logging.DEBUG, format=LOG_FORMAT)
160     else:
161         logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
163     gl = GitLabWrapper.from_config(
164         PYTHON_GITLAB_NAME, config_files=[PYTHON_GITLAB_CONFIG_FILE]
165     )
166     gl.auth()
168     group = gl.groups.list(search=GROUP_NAME)[0]
169     assert isinstance(group, gitlab.v4.objects.Group)
171     changes = ChangelogGenerator(gl, group, args.version).changes()
173     log.debug(pprint.PrettyPrinter().pformat(changes))
175     print(
176         changelog_entry(
177             version=args.version,
178             date=datetime.datetime.now(datetime.UTC),
179             changes=changes,
180         )
181     )