From b40e6093c8940f5035de5028d0c48fa201ec924f Mon Sep 17 00:00:00 2001 From: Tim Jaacks <tim.jaacks@garz-fricke.com> Date: Mon, 16 Nov 2020 11:04:40 +0100 Subject: [PATCH] Add gitlab ci scripts --- .gitlab-ci.yml | 32 ++++++ accept_merge_request.py | 144 ++++++++++++++++++++++++++ check_pipeline_status.py | 66 ++++++++++++ common.py | 107 +++++++++++++++++++ integrate_into_manifest.py | 203 +++++++++++++++++++++++++++++++++++++ merge_into_manifest.py | 179 ++++++++++++++++++++++++++++++++ pylintrc | 10 ++ test.py | 24 +++++ 8 files changed, 765 insertions(+) create mode 100644 .gitlab-ci.yml create mode 100755 accept_merge_request.py create mode 100755 check_pipeline_status.py create mode 100755 common.py create mode 100755 integrate_into_manifest.py create mode 100755 merge_into_manifest.py create mode 100644 pylintrc create mode 100755 test.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..0b035fb5 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,32 @@ +#------------------------------------------------------------------------------- +# Global +#------------------------------------------------------------------------------- +image: + name: "python:3.9" + +stages: + - analyze + +#------------------------------------------------------------------------------- +# Stage: analyze +#------------------------------------------------------------------------------- +pylint: + stage: analyze + timeout: 2m + before_script: + # FIXME: prepare docker image with modules already installed + - pip3 install furl + - pip3 install gitpython + - pip3 install lxml + - pip3 install python-gitlab + - pip3 install pylint + script: + - pylint --rcfile=pylintrc *.py + +black: + stage: analyze + timeout: 2m + before_script: + - pip3 install black + script: + - black --diff --check *.py diff --git a/accept_merge_request.py b/accept_merge_request.py new file mode 100755 index 00000000..b3006c32 --- /dev/null +++ b/accept_merge_request.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +import common + +import argparse +import sys +import time +from gitlab import ( + Gitlab, + GitlabGetError, + GitlabMRClosedError, +) + +critical_error = ( + "This is a critical error! Please make sure to:\n" + " 1. merge the above-mentioned merge request by hand as soon as possible\n" + " (this is important, otherwise following merge requests will get stuck, too)\n" + " 2. examine why this has happened and fix it in the CI pipeline" +) + + +def accept_merge_request(gitlab, project, mr, rebase=False): + """Attempt to merge a merge request, rebase if necessary""" + merged = False + pipeline_pending = False + + while not merged: + # Update merge request before trying to merge it in order to get the latest + # pipeline status + try: + updated_mr = project.mergerequests.get(id=mr.iid) + mr = updated_mr + except GitlabGetError as e: + print("WARNING: Could not update merge request object: %s" % e) + + # Try to merge the merge request + try: + mr.merge() + merged = True + if pipeline_pending: + print("") + + except GitlabMRClosedError as e: + # See HTTP error codes for merge requests here: + # https://docs.gitlab.com/ce/api/merge_requests.html#accept-mr + + if e.response_code == 405: + # Not allowed (draft, closed, pipeline pending or failed) + # If pipeline is running, wait for completion + if not mr.head_pipeline: + # No pipeline created yet + print("No pipeline created yet") + time.sleep(1) + elif mr.head_pipeline["status"] in common.pending_states: + # Pipeline pending + if not pipeline_pending: + print("Waiting for pending pipeline", end="", flush=True) + pipeline_pending = True + print(".", end="", flush=True) + time.sleep(1) + else: + # Merge conflict, automatic rebase not possible + if pipeline_pending: + print("") + print("Merge not possible, has to be rebased manually") + return False + + elif e.response_code == 406: + # Merge conflict, automatic rebase is possible + if pipeline_pending: + print("") + pipeline_pending = False + print("Merge not possible, but branch can be automatically rebased") + if rebase: + print("Trying to rebase...") + mr = common.rebase_merge_request(gitlab, project, mr) + if mr.merge_error: + print("ERROR: rebase not possible\n'%s'" % mr.merge_error) + sys.exit(critical_error) + print("Sucessfully rebased") + else: + return False + + else: + if pipeline_pending: + print("") + print("ERROR: merge not possible: %s" % e) + sys.exit(critical_error) + + return True + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--gitlab-url", + help="""URL to the GitLab instance""", + dest="gitlab_url", + required=True, + ) + parser.add_argument( + "--token", + help="""GitLab REST API private access token""", + dest="token", + required=True, + ) + parser.add_argument( + "--project", + help="""name of the GitLab project""", + dest="project", + required=True, + ) + parser.add_argument( + "--merge-request", + help="""id of the merge request""", + dest="merge_request", + required=True, + ) + parser.add_argument( + "--rebase", + help="""attempt to automatically rebase merge request if necessary""", + dest="rebase", + action="store_true", + required=False, + ) + + args, _ = parser.parse_known_args() + + gitlab = Gitlab(args.gitlab_url, private_token=args.token) + project = common.get_project(gitlab, args.project) + + with gitlab: + try: + merge_request = project.mergerequests.get(id=args.merge_request) + except GitlabGetError as e: + sys.exit("Could not get merge request: %s" % e) + + if accept_merge_request(gitlab, project, merge_request, rebase=args.rebase): + print("Successfully merged") + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/check_pipeline_status.py b/check_pipeline_status.py new file mode 100755 index 00000000..c02108d8 --- /dev/null +++ b/check_pipeline_status.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +import common + +import argparse +import sys +import time +from gitlab import Gitlab + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--gitlab-url", + help="""URL to the GitLab instance""", + dest="gitlab_url", + required=True, + ) + parser.add_argument( + "--token", + help="""GitLab REST API private access token""", + dest="token", + required=True, + ) + parser.add_argument( + "--project", + help="""name of the GitLab project""", + dest="project", + required=True, + ) + parser.add_argument( + "--commit", + help="""sha of the project commit to check""", + dest="commit", + required=True, + ) + + args, _ = parser.parse_known_args() + + with Gitlab(args.gitlab_url, private_token=args.token) as gitlab: + + # Find project + project = common.get_project(gitlab, args.project) + + # Find pipeline for commit + pipelines = project.pipelines.list(sha=args.commit) + if not pipelines: + print("ERROR: no pipeline for commit '%s' found" % args.commit) + sys.exit(1) + pipeline = pipelines[0] + + # Wait for pipeline termination + print("Waiting for pipeline %s" % pipeline.web_url) + terminated_states = ["success", "failed", "canceled", "skipped"] + while pipeline.status not in terminated_states: + print(".", end="", flush=True) + time.sleep(1) + pipeline = project.pipelines.get(pipeline.id) + print("") + + print("Result: %s" % pipeline.status) + if pipeline.status != "success": + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/common.py b/common.py new file mode 100755 index 00000000..f48a9575 --- /dev/null +++ b/common.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 + +import requests +import sys +import time +from gitlab import GitlabAuthenticationError, GitlabGetError, GitlabMRRebaseError + + +manifest_file = "default.xml" +pending_states = ["created", "waiting_for_resource", "preparing", "pending", "running"] + + +def integration_branch_name(project_name, branch_name): + """Get integration branch name""" + return "integrate/" + project_name + "/" + branch_name + + +def get_project(gitlab, project_name): + """Get a GitLab project by its name""" + with gitlab: + project = None + try: + for p in gitlab.projects.list( + search=project_name, retry_transient_errors=True + ): + if p.name == project_name: + project = p + if not project: + sys.exit("ERROR: project '%s' not found" % project_name) + except requests.ConnectionError: + sys.exit("ERROR: could not connect to GitLab server") + except GitlabAuthenticationError: + sys.exit("ERROR: authentication failed") + return project + + +def get_latest_commit(gitlab, project, branch_name): + """Get latest commit on a given project branch""" + with gitlab: + try: + branch = project.branches.get(branch_name) + except GitlabGetError as e: + sys.exit( + "ERROR: could not get branch '%s' for project '%s': %s" + % (branch_name, project.name, e) + ) + if not branch: + sys.exit( + "ERROR: branch '%s' not found in project %s" + % (branch_name, project.name) + ) + return branch.commit + + +def get_merge_request( + gitlab, project, state, source_branch=None, target_branch=None, commit=None +): + """Get merge request by source and target branch and optionally commit sha""" + merge_request = None + with gitlab: + try: + merge_requests = project.mergerequests.list( + source_branch=source_branch, + target_branch=target_branch, + state=state, + all=True, + retry_transient_errors=True, + ) + except GitlabGetError as e: + sys.exit( + "ERROR: could not list merge requests for project '%s': %s" + % (project.name, e) + ) + if commit: + for mr in merge_requests: + if mr.sha == commit: + merge_request = mr + elif merge_requests: + merge_request = merge_requests[0] + return merge_request + + +def rebase_merge_request(gitlab, project, merge_request): + """Attempt to rebase a merge request and return the updated merge request object""" + # Rebasing takes more than one API call, see: + # https://docs.gitlab.com/ce/api/merge_requests.html#rebase-a-merge-request + with gitlab: + try: + merge_request.rebase() + except GitlabMRRebaseError as e: + merge_request.merge_error = "Could not rebase merge request: %s" % e + return merge_request + rebase_in_progress = True + while rebase_in_progress: + time.sleep(1) + try: + updated_merge_request = project.mergerequests.get( + id=merge_request.iid, + query_parameters={"include_rebase_in_progress": "True"}, + ) + except GitlabGetError as e: + merge_request.merge_error = ( + "Could not get updated merge request: %s" % e + ) + return merge_request + rebase_in_progress = updated_merge_request.rebase_in_progress + return updated_merge_request diff --git a/integrate_into_manifest.py b/integrate_into_manifest.py new file mode 100755 index 00000000..101bfeb4 --- /dev/null +++ b/integrate_into_manifest.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +import common + +import argparse +import sys +import tempfile +from furl import furl +from git import Actor, GitCommandError, Repo +from gitlab import Gitlab, GitlabGetError +from lxml import etree + + +def integrate_into_manifest( + gitlab, + manifest_project, + integration_base, + manifest_file, + project, + branch, + commit=None, +): + with tempfile.TemporaryDirectory() as manifest_dir: + manifest_filepath = manifest_dir + "/" + manifest_file + + # Construct clone url containing access token + clone_url = furl(manifest_project.http_url_to_repo) + clone_url.username = "gitlab-ci" + clone_url.password = gitlab.private_token + + # Checkout manifest + try: + manifest_repo = Repo.clone_from(clone_url.url, manifest_dir) + manifest_repo.heads[integration_base].checkout() + except GitCommandError as e: + sys.exit("ERROR: could not clone manifest repository\n" + str(e)) + except IndexError as e: + sys.exit("ERROR: branch '%s' not found" % integration_base) + + # Create integration branch (delete former one if already exists) + integration_branch = common.integration_branch_name(project.name, branch) + integration_base = None + for ref in manifest_repo.references: + if integration_branch == ref.name: + manifest_repo.delete_head(ref) + integration_base = manifest_repo.create_head(integration_branch) + manifest_repo.head.reference = integration_base + + # Parse manifest file + try: + manifest = etree.parse(manifest_filepath) + except FileNotFoundError: + sys.exit("ERROR: file '%s' not found in manifest repo" % manifest_file) + + # Find project references in manifest + project_nodes = manifest.findall("project[@name='%s']" % project.path) + if not project_nodes: + sys.exit("ERROR: project '%s' not found in manifest" % project.path) + project_node = project_nodes[0] + + # Get current project revision + old_revision = project_node.get("revision") + + # Get project revision + if commit: + try: + project_commit = project.commits.get(commit) + except GitlabGetError as e: + sys.exit( + "ERROR: could not get commit '%s' for project '%s': %s" + % (commit, project.name, e) + ) + # project_commit is a Gitlab.ProjectCommit object here + new_revision = project_commit.id + commit_message = project_commit.message + else: + project_commit = common.get_latest_commit(gitlab, project, branch) + # project_commit is a simple dictionary here + new_revision = project_commit["id"] + commit_message = project_commit["message"] + + # Update manifest file + # We are doing this using a plain text replace action. Unfortunately + # all python libraries for handling XML data are not able to preserve + # the file layout, and we want a minimal diff. + with open(manifest_filepath, "r") as file: + content = file.read() + content = content.replace(old_revision, new_revision) + with open(manifest_filepath, "w") as file: + file.write(content) + + # Construct commit object and commit change + gitlab.auth() + author = Actor(gitlab.user.username, gitlab.user.email) + commit_link = project.web_url + "/-/commit/" + new_revision + manifest_repo.index.add([manifest_file]) + manifest_repo.index.commit( + "Integrate %s/%s\n\n" % (project.path, branch) + + "Commit: %s\n\n" % (commit_link) + + commit_message, + author=author, + committer=author, + ) + + # Push commit + try: + origin = manifest_repo.remote("origin") + origin.push(integration_branch, force=True) + except GitCommandError as e: + sys.exit("ERROR: could not commit changes\n" + str(e)) + + # Print commit information + manifest_revision = manifest_repo.head.commit.hexsha + print("Pushed new commit:") + print(manifest_project.web_url + "/-/commit/" + manifest_revision) + print(manifest_repo.git.show("--summary", "--decorate")) + + return manifest_revision + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--gitlab-url", + help="""URL to the GitLab instance""", + dest="gitlab_url", + required=True, + ) + parser.add_argument( + "--token", + help="""GitLab REST API private access token""", + dest="token", + required=True, + ) + parser.add_argument( + "--manifest-project", + help="""name of the manifest project""", + dest="manifest_project", + required=True, + ) + parser.add_argument( + "--integration-base", + help="""manifest branch to branch off from""", + dest="integration_base", + required=True, + ) + parser.add_argument( + "--manifest-file", + help="""manifest file name (default: 'default.xml')""", + dest="manifest_file", + default=common.manifest_file, + required=False, + ) + parser.add_argument( + "--project", + help="""name of the project, as specified in the manifest""", + dest="project", + required=True, + ) + parser.add_argument( + "--branch", + help="""project branch to be integrated""", + dest="branch", + required=True, + ) + parser.add_argument( + "--commit", + help="""project commit""", + dest="commit", + default=None, + required=False, + ) + parser.add_argument( + "--save-revision-to", + help="""path to a file where the new manifest revision is stored""", + dest="revision_file", + required=False, + ) + + args, _ = parser.parse_known_args() + + gitlab = Gitlab(args.gitlab_url, private_token=args.token) + + manifest_project = common.get_project(gitlab, args.manifest_project) + project = common.get_project(gitlab, args.project) + + with gitlab: + manifest_revision = integrate_into_manifest( + gitlab=gitlab, + manifest_project=manifest_project, + integration_base=args.integration_base, + manifest_file=args.manifest_file, + project=project, + branch=args.branch, + commit=args.commit, + ) + + if args.revision_file: + with open(args.revision_file, "w") as file: + file.write(manifest_revision) + + +if __name__ == "__main__": + main() diff --git a/merge_into_manifest.py b/merge_into_manifest.py new file mode 100755 index 00000000..98a87bec --- /dev/null +++ b/merge_into_manifest.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +import common + +import argparse +import sys +from gitlab import ( + Gitlab, + GitlabGetError, +) +from accept_merge_request import accept_merge_request +from integrate_into_manifest import integrate_into_manifest + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--gitlab-url", + help="""URL to the GitLab instance""", + dest="gitlab_url", + required=True, + ) + parser.add_argument( + "--token", + help="""GitLab REST API private access token""", + dest="token", + required=True, + ) + parser.add_argument( + "--manifest-project", + help="""name of the manifest project""", + dest="manifest_project", + required=True, + ) + parser.add_argument( + "--master-branch", + help="""master branch to merge changes into""", + dest="master_branch", + required=True, + ) + parser.add_argument( + "--project", + help="""name of the project containing the commit to be merged""", + dest="project", + required=True, + ) + parser.add_argument( + "--commit", + help="""sha of the commit to be merged""", + dest="commit", + required=True, + ) + parser.add_argument( + "--save-revision-to", + help="""path to a file where the new manifest revision is stored""", + dest="revision_file", + required=False, + ) + + args, _ = parser.parse_known_args() + + gitlab = Gitlab(args.gitlab_url, private_token=args.token) + project = common.get_project(gitlab, args.project) + manifest_project = common.get_project(gitlab, args.manifest_project) + + # Get source merge request + source_mr = common.get_merge_request( + gitlab, + project, + target_branch=args.master_branch, + state="merged", + commit=args.commit, + ) + if not source_mr: + sys.exit( + "ERROR: could not determine source merge request for commit %s" + % args.commit + ) + + # Get original branch for commit + original_branch = source_mr.source_branch + integration_branch = common.integration_branch_name(project.name, original_branch) + target_branch = args.master_branch + + # Check if merge request already exists + mr = common.get_merge_request( + gitlab, + manifest_project, + source_branch=integration_branch, + target_branch=target_branch, + state="opened", + ) + if mr: + sys.exit("ERROR: There is already an open merge request:\n%s" % mr.web_url) + + with gitlab: + + # Check if branches exist + try: + manifest_project.branches.get(integration_branch) + except GitlabGetError: + sys.exit("ERROR: source branch '%s' does not exist." % integration_branch) + try: + manifest_project.branches.get(target_branch) + except GitlabGetError: + sys.exit("ERROR: target branch '%s' does not exist." % target_branch) + + # Create new merge request + mr = manifest_project.mergerequests.create( + { + "source_branch": integration_branch, + "target_branch": target_branch, + "remove_source_branch": True, + "title": "Merge " + integration_branch, + } + ) + + print("Created new merge request:") + print(mr.web_url) + + # Insert cross-links in both merge requests + mr.notes.create({"body": "Source merge request: %s" % source_mr.web_url}) + source_mr.notes.create({"body": "Integration merge request: %s" % mr.web_url}) + + # Attempt to merge, reintegrate if necessary + manifest_revision = mr.sha + merged = False + while not merged: + merged = accept_merge_request(gitlab, manifest_project, mr) + if not merged: + # Note: if reintegration is necessary here, the source merge request was + # merged without running the pipeline on the latest manifest, i.e. some + # other project has been merged in between. Automatic rebase is not + # possible, though, because the altered manifest lines are part of the + # diff context, so we have to create a new integration commit. This + # possibly might result in a failing pipeline state on the master. + manifest_revision = integrate_into_manifest( + gitlab=gitlab, + manifest_project=manifest_project, + integration_base=target_branch, + manifest_file=common.manifest_file, + project=project, + branch=original_branch, + commit=args.commit, + ) + + print("Successfully merged") + + # Check if there is a running pipeline on the integration branch and cancel it. + # This happens when a reintegration was necessary before merging. The instant + # merge afterwards deletes the integration branch, so the pipeline on the branch + # will fail, because repo cannot checkout the code anymore. In order not to + # confuse people, we better cancel it. Since there is a pipeline running on the + # master after merging, we don't need the branch results anyway. + try: + pipelines = manifest_project.pipelines.list( + sha=manifest_revision, + ref=integration_branch, + retry_transient_errors=True, + ) + except GitlabGetError: + print("WARNING: could not list pipelines for project '%s'" % project.name) + if pipelines: + pipeline = pipelines[0] + if pipeline.status in common.pending_states: + pipeline.cancel() + print( + "Cancelling running pipeline for integration branch '%s':" + % integration_branch + ) + print(pipeline.web_url) + + # Write new manifest revision to file for next pipeline stage + if args.revision_file: + with open(args.revision_file, "w") as file: + file.write(manifest_revision) + + +if __name__ == "__main__": + main() diff --git a/pylintrc b/pylintrc new file mode 100644 index 00000000..6bc07365 --- /dev/null +++ b/pylintrc @@ -0,0 +1,10 @@ +[MESSAGES CONTROL] + +# Disable some verifications +disable = + C, # conventions + R, # refactoring + W0511, # fixme warnings + +# Whitelist lxml package to disable I1101 +extension-pkg-whitelist = lxml diff --git a/test.py b/test.py new file mode 100755 index 00000000..e33c1951 --- /dev/null +++ b/test.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +import common + +from gitlab import Gitlab + + +def main(): + gitlab = Gitlab("https://gitlab.com", private_token="9q7XsWMpiv_MM5z6HiHw") + project = common.get_project(gitlab, "minimal-foo") + + with gitlab: + merge_request = common.get_merge_request( + gitlab, + project, + state="merged", + source_branch="remove-file-3", + target_branch="master", + commit="1d5c8da0a4826cceef1abf99c23d7c3585754f19", + ) + print(merge_request) + + +if __name__ == "__main__": + main() -- GitLab