#!/usr/bin/env python3 """ Simple changelog generator for Garz&Fricke gitlab projects. Queries merge request from gitlab and outputs as sorted list in markdown format. Releases are tags on the given branch in the manifest project. Changes to list are merged mergerequests with the given branch as target. Match with releases is done by the timestamp (merged_at) in comparison with the tags timestamp. """ import argparse import datetime import logging import sys import gitlab as gl __author__ = "Jonas Höppner" __email__ = "jonas.hoeppner@garz-fricke.com" GITLAB_SERVER = "https://git.seco.com" # ID of the guf_yocto group GITLAB_GROUP_ID = "556" DISTRO_PROJECT_ID = "1748" MACHINE_PROJECT_ID = "2074" MANIFEST_PROJECT_ID = "1725" DEFAULTBRANCH = "dunfell" GITLAB_TIMEFORMAT = "%Y-%m-%dT%H:%M:%S.%f%z" TIMEFORMAT = "%Y-%m-%d %H:%M" verbose = 0 def decode_timestamp(t): timestamp = datetime.datetime.strptime(t, GITLAB_TIMEFORMAT) return timestamp class Project: def __init__(self, project): self.project = project def __str__(self): return "## Project " + self.project.name + "\n" def withlink(self): return ( "\n\n## Project [" + self.project.name + "](" + self.project.web_url + ")\n" ) def __eq__(self, p): if not p: return False return self.project.id == p.project.id class Tag: def __init__(self, tag): self.name = tag.name self.message = tag.message self.commit = tag.commit """ The tags timestamp is a little more complicated it normally points to the tagged commit's timestamps. But the merge happens later. To handle this, the relelated mergerequest is found by comparing the sha's and also take the merged_at timestamp. """ self.timestamp = decode_timestamp(tag.commit["created_at"]) """ The mr which introduced the taged commit as gitlab-python does not support the V5 API yet this is added later when traversing the mrs anyway with V5 Api: https://docs.gitlab.com/ee/api/commits.html#list-merge-requests-associated-with-a-commit """ self.mergerequest = None logging.debug(self.name + " -- " + self.commit["id"]) def __str__(self): return self.name + " " + self.timestamp.strftime(TIMEFORMAT) def add_mergerequest(self, m): if self.mergerequest: return if m.mr.sha == self.commit["id"]: self.mergerequest = m # Update timestamp # The tag points to the commit, but the merge of the merge request may has happend later # as the commit, so the merged_at date is relevant. Otherwise the tagged commit and may be # more end up in the wrong release new_timestamp = decode_timestamp(self.mergerequest.mr.merged_at) logging.debug("Found matching merge request for %s", self) logging.debug(" - %s", self.timestamp.strftime(TIMEFORMAT)) logging.debug(" - %s", new_timestamp.strftime(TIMEFORMAT)) self.timestamp = new_timestamp def header(self): return ( "\n\n\n# Release " + self.name + "\n\nreleased at " + self.timestamp.strftime(TIMEFORMAT) + "\n\n" ) class DummyTag: def __init__( self, name, message, date=datetime.datetime.now(tz=datetime.timezone.utc) ): self.name = name self.message = message self.timestamp = date def header(self): return "\n\n\n# " + self.name + "\n\n" def add_mergerequest(self, m): # Needed as interface but does nothing pass class Release: """Store some release data""" def __init__(self, tag): self.tag = tag self.mergerequests = [] def add_mergerequest(self, m): # Check if this merge_request is related to the tag self.tag.add_mergerequest(m) # Adds a mergerequest to the project, but uses some filtering # Ignore automated merge requests if m.mr.author["username"] == "guf-gitbot": return False if m.mr.author["username"] == "gitbot": return False # With the movement to git.seco.com the MRs owned by # the guf-gitbot have been transfered to tobias # As it is not possible to change the owner back # to gitbot we need an extra filter here on the # branch name if m.mr.source_branch.startswith("integrate/"): return False # Timestamp is not in this release if self.tag.timestamp < m.timestamp: return False # Remove duplicates, don't print the same title # twice in the same project and release if any( a.mr.title == m.mr.title and a.project == m.project for a in self.mergerequests ): return True self.mergerequests.append(m) return True def header(self): return self.tag.header() def description(self): m = self.tag.message if not m: return "" return m def __str__(self): return self.tag.name class MergeRequest: def __init__(self, mr, p): self.mr = mr self.project = p self.timestamp = decode_timestamp(self.mr.merged_at) logging.debug("\nMergeRequest:") logging.debug(mr) def __str__(self): return self.mr.title def withlink(self): out = self.mr.title + " [" + self.mr.reference + "](" + self.mr.web_url + ")" return out def main(args): parser = argparse.ArgumentParser(description=__doc__, usage="%(prog)s [OPTIONS]") parser.add_argument( "--gitlab-url", help="""URL to the GitLab instance""", dest="gitlab_url", action="store", default=GITLAB_SERVER, ) parser.add_argument( "--token", help="""GitLab REST API private access token""", dest="token", required=True, ) parser.add_argument( "-b", "--branch", action="store", dest="branch", default=DEFAULTBRANCH, help=("Specify the branch to work on, default is dunfell."), ) parser.add_argument( "-v", "--verbose", action="count", dest="verbose", default=0, help=("Increase verbosity."), ) options = parser.parse_args(args) if options.verbose: logging.basicConfig(level=logging.DEBUG) logging.debug(options) gitlab = gl.Gitlab(options.gitlab_url, private_token=options.token) # Speed up, complete project lookup takes much longer # then specifying the ID directly distro = Project(gitlab.projects.get(DISTRO_PROJECT_ID)) machine = Project(gitlab.projects.get(MACHINE_PROJECT_ID)) manifest = Project(gitlab.projects.get(MANIFEST_PROJECT_ID)) releases = [] for t in manifest.project.tags.list(search=options.branch): releases.append(Release(Tag(t))) # Add dummy release with date today for new untaged commits releases.append( Release( DummyTag( "Not yet released", "Merged Request already merged into " + options.branch + " but not yet released.", ) ) ) # Sort by date, oldest first releases = sorted(releases, key=lambda d: d.tag.timestamp, reverse=False) for p in [manifest, distro, machine]: for mr in p.project.mergerequests.list( scope="all", state="merged", target_branch=options.branch, all=True ): m = MergeRequest(mr, p) for r in releases: if r.add_mergerequest(m): break # Sort by date, newest first releases = sorted(releases, key=lambda d: d.tag.timestamp, reverse=True) for r in releases: # Don't show empty releases/tags if not len(r.mergerequests): continue print(r.header()) print(r.description()) current_project = None for m in r.mergerequests: if m.project != current_project: current_project = m.project print(current_project.withlink()) print(" - ", m.withlink()) if __name__ == "__main__": main(sys.argv[1:])