Skip to content
Snippets Groups Projects
  • Jonas Höppner's avatar
    Changelog-generator: Add parameter to include all integrated projects · 03af7519
    Jonas Höppner authored
    Before this, the changelog was generated on a given list of projects,
    all using the same branch.
    The new parameter now goes the the specified projects from the
    parameters, and detects all integrated projects into this.
    So normally you would specify one or two manifest projects, everything
    else is automatically added to the list.
    
    This also handles different branch names. As for example the kernel
    branches are different, the integration branch is used instead of the
    branch from the command line.
    03af7519
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
changelog_generator.py 9.33 KiB
#!/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

import common
from get_integration_sources import get_integration_sources

__author__ = "Jonas Höppner"
__email__ = "jonas.hoeppner@garz-fricke.com"

GITLAB_SERVER = "https://git.seco.com"

GITLAB_TIMEFORMAT = "%Y-%m-%dT%H:%M:%S.%f%z"
TIMEFORMAT = "%Y-%m-%d %H:%M"

verbose = 0


def decode_timestamp(t):
    return datetime.datetime.strptime(t, GITLAB_TIMEFORMAT)


class Project:
    def __init__(self, project):
        self.project = project

    def __str__(self):
        return f"## Project {self.project.name}\n"

    def withlink(self):
        return f"\n\n## Project [{self.project.name}]({self.project.web_url})\n"

    def __eq__(self, p):
        return self.project.id == p.project.id if p else False


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(f"{self.name}  -- {self.commit['id']}")

    def __str__(self):
        return f"{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(f"Found matching merge request for {self}")
            logging.debug(f" - {self.timestamp.strftime(TIMEFORMAT)}")
            logging.debug(f" - {new_timestamp.strftime(TIMEFORMAT)}")
            self.timestamp = new_timestamp

    def header(self):
        return (
            f"\n\n\n# Release {self.name}"
            f"\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 f"\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
        return m or ""

    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):
        return f"{self.mr.title} [{self.mr.reference}]({self.mr.web_url})"


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",
        default=common.GITLAB_URL,
    )
    parser.add_argument(
        "--token",
        help="""GitLab REST API private access token""",
        dest="token",
        required=True,
    )
    parser.add_argument(
        "--project",
        help="""Project to get merge requests from
                (can be passed multiple times, tags are read from the first one)""",
        dest="project",
        action="append",
        required=True,
    )
    parser.add_argument(
        "-b",
        "--branch",
        help="""Branch to work on""",
        dest="branch",
        required=True,
    )

    parser.add_argument(
        "-a",
        "--all-projects",
        help="""Parse all projects integrated into the listed ones""",
        action="store_true",
        default=False,
        required=False,
    )

    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)

    project_list = {}
    manifest_project = options.project[0]
    if not options.all_projects:
        # Old behaviour: Just a list of all projects from the commandline,
        # only one branch name
        for project in options.project:
            project_list[project] = {"branch": options.branch}
    else:
        # New behaviour: Go through the project list from the parameters
        # and check for all integrated projects
        groupname = options.project[0].split("/")[0]
        group = gitlab.groups.get(groupname, retry_transient_errors=True)
        for project in options.project:
            project_list[project] = {"branch": options.branch}

            integration_sources = get_integration_sources(
                project, options.branch, group
            )
            for source in integration_sources:
                project_list[source["project"]] = {"branch": source["branch"]}

    if options.verbose:
        print("Projects included in the report:")
        for p, b in project_list.items():
            print(f"  {p}  - {b['branch']}")

    for p in project_list.keys():
        gl_project = Project(gitlab.projects.get(p))
        project_list[p]["gitlab"] = gl_project

    releases = [
        Release(Tag(t))
        for t in project_list[manifest_project]["gitlab"].project.tags.list(
            search=project_list[manifest_project]["branch"], as_list=False
        )
    ]
    # Add dummy release with date today for new unstaged commits
    releases.append(
        Release(
            DummyTag(
                "Not yet released",
                f"Merge Requests 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 project_list.values():
        for mr in p["gitlab"].project.mergerequests.list(
            scope="all", state="merged", target_branch=p["branch"], as_list=False
        ):
            m = MergeRequest(mr, p["gitlab"])
            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:])