3 # Documentation: https://tails.boum.org/contribute/working_together/GitLab/#api
15 from pathlib import Path
17 PYTHON_GITLAB_CONFIG_FILE = os.getenv('PYTHON_GITLAB_CONFIG_FILE',
21 PYTHON_GITLAB_NAME = os.getenv('GITLAB_NAME', default='Tails')
25 # Only changes in these projects are considered
27 GROUP_NAME + '/' + project for project in [
34 # Merge requests that modify only files whose path match one of IGNORED_PATHS
36 # Patterns will be passed to re.fullmatch().
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):
60 self.version = version
62 def merge_requests(self, milestone) -> list:
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
73 project = self.gl.project(mr.target_project_id)
74 mr = project.mergerequests.get(mr.iid)
75 if ignore_merge_request(mr):
79 "ref": mr.references['full'],
81 "web_url": mr.web_url,
84 "ref": project.issues.get(issue.iid).references['full'],
87 for issue in mr.closes_issues()
90 commit.title for commit in mr.commits()
91 # Ignore merge commits
92 if len(project.commits.get(commit.id).parent_ids) == 1
99 def changes(self) -> dict:
100 milestone_title = "Tails_" + self.version
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
106 assert isinstance(milestone, gitlab.v4.objects.GroupMilestone)
109 "merge_requests": self.merge_requests(milestone),
110 "issues": {}, # Let's see if we really need this; probably not.
114 def ignore_merge_request(mr) -> bool:
115 for change in mr.changes()['changes']:
116 for path in [change['old_path'], change['new_path']]:
118 re.fullmatch(ignored_path, path) is None
119 for ignored_path in IGNORED_PATHS
121 log.debug("Returning false")
123 log.debug("Returning true")
127 def changelog_entry(version: str, date: datetime, changes: dict):
128 jinja2_env = jinja2.Environment(
129 loader=jinja2.FileSystemLoader('config/release_management/templates'),
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),
142 if __name__ == '__main__':
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()
150 logging.basicConfig(level=logging.DEBUG, format=LOG_FORMAT)
152 logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
154 gl = GitLabWrapper.from_config(PYTHON_GITLAB_NAME,
155 config_files=[PYTHON_GITLAB_CONFIG_FILE])
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))
166 changelog_entry(version=args.version,
167 date=datetime.datetime.now(datetime.timezone.utc),