diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index cb98394af493f9c5a5e699e1bc14eee47c4c98ad..632b1258ffb217d2bb142d490c61f9d010921e25 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -10,6 +10,11 @@ default: stages: - analyze + - deploy + +workflow: + rules: + - if: $CI_MERGE_REQUEST_IID #--------------------------------------------------------------------------------------- # Stage: analyze @@ -31,3 +36,32 @@ executable: timeout: 2m script: - (! find ! -executable -name "*.py" -exec echo "not executable:"" {}" \; | grep .) + +#--------------------------------------------------------------------------------------- +# Stage: deploy +#--------------------------------------------------------------------------------------- +.deploy: &deploy + stage: deploy + rules: + - if: $CI_MERGE_REQUEST_IID + when: manual + - if: $CI_COMMIT_BRANCH == "master" + when: manual + allow_failure: yes + script: + - cd ${CI_PROJECT_DIR} + - if [[ "$CI_COMMIT_BRANCH" == "master" ]]; then MERGE="--merge"; else MERGE=""; fi + - ./deploy_gitlab_ci.py + --gitlab-url=${CI_SERVER_URL} + --token=${GITBOT_TOKEN} + --project=${CI_JOB_NAME} + --submodule=.gitlab-ci + --revision=${CI_COMMIT_SHA} + ${MERGE} + +garz-fricke/yocto/infrastructure/ci-test/minimal-bar: *deploy +garz-fricke/yocto/infrastructure/ci-test/minimal-foo: *deploy +garz-fricke/yocto/infrastructure/ci-test/minimal-manifest: *deploy +garz-fricke/yocto/layers/meta-guf-distro: *deploy +garz-fricke/yocto/layers/meta-guf-machine: *deploy +garz-fricke/yocto/manifest: *deploy diff --git a/common.py b/common.py index 10b23fe7d1744022f2159319b7bf323093f96c14..4b7e1f74fd024c107bcab7b3e122c5a686c527a1 100755 --- a/common.py +++ b/common.py @@ -3,7 +3,11 @@ import requests import sys import time +from git import Actor, GitCommandError +from git.repo.base import Repo from gitlab import GitlabAuthenticationError, GitlabGetError, GitlabMRRebaseError +from gitlab.v4.objects import Project +from gitlab.v4.objects import MergeRequest manifest_file = "default.xml" @@ -79,3 +83,64 @@ def rebase_merge_request(project, merge_request): return merge_request rebase_in_progress = updated_merge_request.rebase_in_progress return updated_merge_request + + +def crosslink_merge_requests(source_mr: MergeRequest, integration_mr: MergeRequest): + """Insert cross-links in merge requests""" + integration_mr.notes.create( + {"body": "Source merge request: %s" % source_mr.web_url} + ) + source_mr.notes.create( + {"body": "Integration merge request: %s" % integration_mr.web_url} + ) + + +def wait_until_merge_status_is_set(project: Project, mr: MergeRequest): + """Periodically query MR until GitLab has checked its merge status""" + print("Waiting until merge status has been checked", end="", flush=True) + unchecked_states = ["unchecked", "checking", "cannot_be_merged_recheck"] + while mr.merge_status in unchecked_states: + print(".", end="", flush=True) + time.sleep(1) + mr = project.mergerequests.get(mr.iid, retry_transient_errors=True) + print(" -> %s" % mr.merge_status) + + +def list_commits(commits): + """Create a list of commits along with the commit messages""" + commit_list = "" + for commit in commits: + commit_list += "\n---\n\nCommit: %s\n\n%s" % (commit.web_url, commit.message) + return commit_list + + +def commit_and_push(project: Project, repo: Repo, branch, message, name, email): + """Commit and push to a repo branch""" + author = Actor(name, email) + repo.index.commit(message, author=author, committer=author) + + # Push commit + try: + origin = repo.remote("origin") + origin.push(branch, force=True) + except GitCommandError as e: + sys.exit("ERROR: could not commit changes\n" + str(e)) + + # Print commit information + revision = repo.head.commit.hexsha + print("Pushed new commit:") + print(project.web_url + "/-/commit/" + revision) + print(repo.git.show("--summary", "--decorate")) + + return revision + + +def get_submodule(repo: Repo, submodule_name): + """Find a submodule in a Git repository by its name""" + submodule = None + for sm in repo.submodules: + if sm.name == submodule_name: + submodule = sm + if submodule is None: + sys.exit("ERROR: submodule '%s' not found" % submodule_name) + return submodule diff --git a/create_merge_request.py b/create_merge_request.py new file mode 100755 index 0000000000000000000000000000000000000000..62123cec92ca7aaab0c5693dab865208232f0734 --- /dev/null +++ b/create_merge_request.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +import common + +import argparse +import sys +from gitlab import Gitlab, GitlabGetError +from gitlab.v4.objects import Project +from get_merge_requests import get_merge_requests + + +def create_merge_request(project: Project, source_branch, target_branch): + """Create a merge request""" + # Check if merge request already exists + mrs = get_merge_requests( + project, + source_branch=source_branch, + target_branch=target_branch, + state="opened", + ) + if mrs: + return mrs[0], False + + # Check if branches exist + try: + project.branches.get(source_branch, retry_transient_errors=True) + except GitlabGetError: + sys.exit("ERROR: source branch '%s' does not exist." % source_branch) + try: + project.branches.get(target_branch, retry_transient_errors=True) + except GitlabGetError: + sys.exit("ERROR: target branch '%s' does not exist." % target_branch) + + # Create new merge request + mr = project.mergerequests.create( + { + "source_branch": source_branch, + "target_branch": target_branch, + "remove_source_branch": True, + "title": "Merge " + source_branch, + } + ) + + return mr, 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( + "--source-branch", + help="""source branch of the merge request""", + dest="source_branch", + required=True, + ) + parser.add_argument( + "--target-branch", + help="""target branch of the merge request""", + dest="target_branch", + required=True, + ) + + args, _ = parser.parse_known_args() + + gitlab = Gitlab(args.gitlab_url, private_token=args.token) + project = common.get_project(gitlab, args.project) + + create_merge_request( + project, + source_branch=args.source_branch, + target_branch=args.target_branch, + ) + + +if __name__ == "__main__": + main() diff --git a/deploy_gitlab_ci.py b/deploy_gitlab_ci.py new file mode 100755 index 0000000000000000000000000000000000000000..30a1dad75b91796b834bda5527a5dbcc6cbd768f --- /dev/null +++ b/deploy_gitlab_ci.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +import common + +import argparse +import sys +from gitlab import Gitlab +from accept_merge_request import accept_merge_request +from create_merge_request import create_merge_request +from get_merge_requests import get_merge_requests +from update_submodule import update_submodule + + +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( + "--submodule", + help="""submodule to update""", + dest="submodule", + required=True, + ) + parser.add_argument( + "--revision", + help="""new revision for submodule""", + dest="revision", + required=True, + ) + parser.add_argument( + "--branch", + help="""project branch (if not default branch)""", + dest="branch", + required=False, + default=None, + ) + parser.add_argument( + "--merge", + help="""if set, perform merge after integration""", + dest="merge", + action="store_true", + required=False, + default=False, + ) + + args, _ = parser.parse_known_args() + + gitlab = Gitlab(args.gitlab_url, private_token=args.token) + project = common.get_project(gitlab, args.project) + + # Update submodule + integration_branch, _, submodule_project = update_submodule( + project, args.submodule, args.revision, args.branch + ) + + # Get source merge request + mrs = get_merge_requests( + submodule_project, + target_branch="master", + commit=args.revision, + ) + if not mrs: + sys.exit( + "ERROR: could not determine source merge request for commit %s" + % args.revision + ) + source_mr = mrs[0] + + # Create merge request + mr, created = create_merge_request( + project, integration_branch, project.default_branch + ) + if created: + common.crosslink_merge_requests(source_mr, mr) + print("Created new merge request:\n%s" % mr.web_url) + else: + print("Existing integration merge request:\n%s" % mr.web_url) + + if not args.merge: + print( + "Skipping automatic merge in MR context. If you like to extend the " + "integration MR by hand, please do it now. Afterwards you can either merge " + "it by hand or re-run this job on the master branch after the source MR " + "has been merged." + ) + sys.exit(0) + + # Wait until GitLab has checked merge status + common.wait_until_merge_status_is_set(project, mr) + + # Attempt to merge + merged = accept_merge_request(project, mr) + + if not merged: + sys.exit("Integration MR could not be merged") + + print("Successfully merged") + + +if __name__ == "__main__": + main() diff --git a/get_merge_requests.py b/get_merge_requests.py index 8846d5afe653bee9d6ae22ce5091db50114badc4..c1fc7c4dcd945d788d0ce072fc1b300d62adfac3 100755 --- a/get_merge_requests.py +++ b/get_merge_requests.py @@ -7,7 +7,7 @@ from gitlab import Gitlab, GitlabGetError def get_merge_requests( - project, state, source_branch=None, target_branch=None, commit=None + project, state=None, source_branch=None, target_branch=None, commit=None ): """Get merge request by source and target branch and optionally commit sha""" merge_requests = [] @@ -15,7 +15,7 @@ def get_merge_requests( all_merge_requests = project.mergerequests.list( source_branch=source_branch, target_branch=target_branch, - state=state, + state=state if state else "all", all=True, retry_transient_errors=True, ) diff --git a/integrate_into_manifest.py b/integrate_into_manifest.py index 68bb9391ca08c2e53385b71c6af48abbd93524b9..986b98ed9c81b9ee5fcfb75408256ae812d7c07d 100755 --- a/integrate_into_manifest.py +++ b/integrate_into_manifest.py @@ -6,7 +6,7 @@ import sys import tempfile from pathlib import Path from furl import furl -from git import Actor, GitCommandError, Repo +from git import GitCommandError, Repo from gitlab import Gitlab, GitlabGetError from lxml import etree @@ -42,12 +42,10 @@ def integrate_into_manifest( integration_branch = common.integration_branch_name( project.name, merge_request.source_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 + manifest_repo.head.reference = manifest_repo.create_head(integration_branch) # Parse manifest file try: @@ -80,30 +78,25 @@ def integrate_into_manifest( content = manifest_filepath.read_text() content = content.replace(old_revision, new_revision) manifest_filepath.write_text(content) + manifest_repo.index.add([manifest_file]) # Make an API request to create the gitlab.user object gitlab.auth() - # Construct commit object and commit change - author = Actor(gitlab.user.username, gitlab.user.email) - message = "Integrate %s/%s\n" % (project.path, merge_request.source_branch) - for commit in merge_request.commits(): - message += "\nCommit: %s\n\n%s" % (commit.web_url, commit.message) - manifest_repo.index.add([manifest_file]) - manifest_repo.index.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")) + # Construct commit message and commit the change + message = "Integrate %s/%s\n%s" % ( + project.path, + merge_request.source_branch, + common.list_commits(merge_request.commits()), + ) + manifest_revision = common.commit_and_push( + manifest_project, + manifest_repo, + integration_branch, + message, + gitlab.user.username, + gitlab.user.email, + ) return manifest_revision diff --git a/merge_into_manifest.py b/merge_into_manifest.py index 531cc6e5dcd8c6680a8fb8029581bc70771d15d6..f580af81f9022e79bc3c7b095351b149bc5dd88b 100755 --- a/merge_into_manifest.py +++ b/merge_into_manifest.py @@ -4,11 +4,9 @@ import common import argparse import sys import time -from gitlab import ( - Gitlab, - GitlabGetError, -) +from gitlab import Gitlab from accept_merge_request import accept_merge_request +from create_merge_request import create_merge_request from get_merge_requests import get_merge_requests from integrate_into_manifest import integrate_into_manifest @@ -38,44 +36,19 @@ def merge_into_manifest(manifest_project, master_branch, project, commit): integration_branch = common.integration_branch_name(project.name, original_branch) target_branch = master_branch - # Check if merge request already exists - mrs = get_merge_requests( - manifest_project, - source_branch=integration_branch, - target_branch=target_branch, - state="opened", + # Create merge request. If there already is a merge request, we assume that it has + # been opened manually and thus will be merged manually as well, so we fail here. + mr, created = create_merge_request( + manifest_project, integration_branch, target_branch ) - # If there already is a merge request, we assume that it has been opened manually - # and thus will be merged manually as well, so we exit with a failure here. - if mrs: + if not created: sys.exit("ERROR: There is already an open merge request:\n%s" % mrs[0].web_url) - # Check if branches exist - try: - manifest_project.branches.get(integration_branch, retry_transient_errors=True) - except GitlabGetError: - sys.exit("ERROR: source branch '%s' does not exist." % integration_branch) - try: - manifest_project.branches.get(target_branch, retry_transient_errors=True) - 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}) + common.crosslink_merge_requests(source_mr, mr) # Loop until MR has been merged successfully manifest_revision = mr.sha @@ -83,13 +56,7 @@ def merge_into_manifest(manifest_project, master_branch, project, commit): while not merged: # Wait until GitLab has checked merge status - print("Waiting until merge status has been checked", end="", flush=True) - unchecked_states = ["unchecked", "checking", "cannot_be_merged_recheck"] - while mr.merge_status in unchecked_states: - print(".", end="", flush=True) - time.sleep(1) - mr = manifest_project.mergerequests.get(mr.iid) - print(" -> %s" % mr.merge_status) + common.wait_until_merge_status_is_set(manifest_project, mr) # Attempt to merge merged = accept_merge_request(manifest_project, mr) @@ -117,7 +84,9 @@ def merge_into_manifest(manifest_project, master_branch, project, commit): while mr.sha != manifest_revision: print(".", end="", flush=True) time.sleep(1) - mr = manifest_project.mergerequests.get(mr.iid) + mr = manifest_project.mergerequests.get( + mr.iid, retry_transient_errors=True + ) print("") print("Successfully merged") diff --git a/update_submodule.py b/update_submodule.py new file mode 100755 index 0000000000000000000000000000000000000000..091fec94dac36d9a53808b8ff531af943aa80272 --- /dev/null +++ b/update_submodule.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +import common + +import argparse +import os +import sys +import tempfile +from furl import furl +from git import GitCommandError, Repo +from gitlab import Gitlab + + +def update_submodule(project, submodule_name, submodule_revision, branch=None): + """Update submodule of gitlab project to given revision""" + + gitlab = project.manager.gitlab + + # If no branch is given, use project's default branch + if branch is None: + branch = project.default_branch + + with tempfile.TemporaryDirectory() as project_dir: + + # Construct clone url containing access token + clone_url = furl(project.http_url_to_repo) + clone_url.username = "gitlab-ci" + clone_url.password = gitlab.private_token + + # Checkout project + try: + repo = Repo.clone_from(clone_url.url, project_dir, branch=branch) + except GitCommandError as e: + sys.exit("ERROR: could not clone repository\n" + str(e)) + except IndexError as e: + sys.exit("ERROR: branch '%s' not found" % branch) + + # Find submodule + submodule = common.get_submodule(repo, submodule_name) + + # Check if revisions are different + if submodule.hexsha == submodule_revision: + print("Submodule is already at %s" % submodule_revision) + return 0 + + # Check for relative path + if not submodule.url.startswith(".."): + sys.exit( + "ERROR: absolute submodule paths are not supported (%s)" % submodule.url + ) + + # Get absolute project path + # This cannot be done with gitpython directly due to issue: + # https://github.com/gitpython-developers/GitPython/issues/730 + relative_path = os.path.splitext(submodule.url)[0] # remove .git + project_path = project.path_with_namespace + while relative_path.startswith(".."): + relative_path = relative_path[3:] # strip off '../' + project_path, _ = os.path.split(project_path) # remove last part + submodule_project_path = os.path.join(project_path, relative_path) + + # Get submodule project + submodule_project = common.get_project(gitlab, submodule_project_path) + + # Get commits between current and new revision + revision_range = submodule.hexsha + ".." + submodule_revision + commits = submodule_project.commits.list(ref_name=revision_range) + if not commits: + sys.exit("ERROR: no commits found in range %s" % revision_range) + + # Find out if top commit is part of a merge request + # If so, use source branch of this MR as integration branch name + # Else use commit sha instead + integration_branch_suffix = submodule_revision + for mr in commits[0].merge_requests(): + if mr["target_branch"] == submodule_project.default_branch: + integration_branch_suffix = mr["source_branch"] + break + + # Initialize submodule + # Hack due to issue above: change to absolute path and switch back afterwards + submodule_clone_url = furl(submodule_project.http_url_to_repo) + submodule_clone_url.username = "gitlab-ci" + submodule_clone_url.password = gitlab.private_token + submodule_relative_url = submodule.url + with submodule.config_writer() as writer: + writer.set("url", submodule_clone_url.url) + submodule.update(init=True) + with submodule.config_writer() as writer: + writer.set("url", submodule_relative_url) + + # Check if integration branch already exists and if it is up to date + integration_branch = common.integration_branch_name( + submodule_project.name, integration_branch_suffix + ) + existing_branch = None + for ref in repo.references: + if "origin/" + integration_branch == ref.name: + existing_branch = ref + if existing_branch: + repo.head.reference = existing_branch + submodule = common.get_submodule(repo, submodule_name) + if submodule.hexsha == submodule_revision: + print( + "Submodule is already at %s on branch %s" + % (submodule_revision, integration_branch) + ) + return (integration_branch, existing_branch.commit, submodule_project) + else: + print("Replacing outdated integration branch %s" % integration_branch) + else: + print("Creating integration branch %s" % integration_branch) + + # Create integration branch + repo.head.reference = repo.create_head(integration_branch) + + # Update submodule + try: + submodule.module().git.checkout(submodule_revision) + except GitCommandError as e: + sys.exit("ERROR: could not checkout commit\n" + str(e)) + repo.git.add(submodule.path) + + # Make an API request to create the gitlab.user object + gitlab.auth() + + # Construct commit message and commit the change + message = "Integrate %s/%s%s\n%s" % ( + submodule_project.name, + integration_branch_suffix, + " and %d more" % (len(commits) - 1) if len(commits) > 1 else "", + common.list_commits(commits), + ) + project_revision = common.commit_and_push( + project, + repo, + integration_branch, + message, + gitlab.user.username, + gitlab.user.email, + ) + + return (integration_branch, project_revision, submodule_project) + + +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( + "--submodule", + help="""submodule to update""", + dest="submodule", + required=True, + ) + parser.add_argument( + "--revision", + help="""new revision for submodule""", + dest="revision", + required=True, + ) + parser.add_argument( + "--branch", + help="""project branch (if not default branch)""", + dest="branch", + required=False, + default=None, + ) + + args, _ = parser.parse_known_args() + + gitlab = Gitlab(args.gitlab_url, private_token=args.token) + project = common.get_project(gitlab, args.project) + + update_submodule(project, args.submodule, args.revision, args.branch) + + +if __name__ == "__main__": + main()