3 # Documentation: https://tails.net/contribute/working_together/GitLab/#api
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")
25 # Only changes in these projects are considered
27 GROUP_NAME + "/" + project
35 # Merge requests that modify only files whose path match one of IGNORED_PATHS
37 # Patterns will be passed to re.fullmatch().
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):
61 self.version = version
63 def merge_requests(self, milestone) -> list:
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
74 project = self.gl.project(gl_mr.target_project_id)
75 mr = project.mergerequests.get(gl_mr.iid)
76 if ignore_merge_request(mr):
80 "ref": mr.references["full"],
82 "web_url": mr.web_url,
85 "ref": project.issues.get(issue.iid).references["full"],
88 for issue in mr.closes_issues()
92 for commit in mr.commits()
93 # Ignore merge commits
94 if len(project.commits.get(commit.id).parent_ids) == 1
101 def changes(self) -> dict:
102 milestone_title = "Tails_" + self.version
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
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)
115 "merge_requests": self.merge_requests(milestone),
116 "issues": {}, # Let's see if we really need this; probably not.
120 def ignore_merge_request(mr) -> bool:
121 for change in mr.changes()["changes"]:
122 for path in [change["old_path"], change["new_path"]]:
125 re.fullmatch(ignored_path, path) is None
126 for ignored_path in IGNORED_PATHS
129 log.debug("Returning false")
131 log.debug("Returning 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"),
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),
150 if __name__ == "__main__":
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()
159 logging.basicConfig(level=logging.DEBUG, format=LOG_FORMAT)
161 logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
163 gl = GitLabWrapper.from_config(
164 PYTHON_GITLAB_NAME, config_files=[PYTHON_GITLAB_CONFIG_FILE]
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))
177 version=args.version,
178 date=datetime.datetime.now(datetime.UTC),