diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index 1db0104..4cf4f61 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -1,4 +1,4 @@ -# Copyright (c) 2024, NVIDIA CORPORATION. +# Copyright (c) 2024-2025, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -28,6 +28,11 @@ on: branch: # github.event.pull_request.base.ref required: true type: string + file_use_base: + description: 'file/path that require to use content of BASE' + default: '' + required: false + type: string secrets: token: required: true @@ -38,47 +43,114 @@ jobs: runs-on: ubuntu-latest steps: - # TODO: make this step as a shared action + - name: set HEAD ENV + run: | + echo "HEAD=${{ inputs.branch }}" >> $GITHUB_ENV + echo "DELETE_HEAD=False" >> $GITHUB_ENV + - name: Generate target branch - id: generate_version run: | - current_branch="${{ inputs.branch }}" - version=${current_branch#branch-} - - IFS='.' read -r -a parts <<< "$version" - year=${parts[0]} - month=${parts[1]} - month=$((10#$month + 2)) - if [ $month -gt 12 ]; then - month=$((month - 12)) - year=$((year + 1)) + # https://github.com/NVIDIA/spark-rapids-common/issues/34 + if [[ "${{ inputs.branch }}" == release/* ]]; then + echo "BASE=main" >> $GITHUB_ENV + else # maintain compatibility support for branch-* + current_branch="${{ inputs.branch }}" + version=${current_branch#branch-} + + IFS='.' read -r -a parts <<< "$version" + year=${parts[0]} + month=${parts[1]} + month=$((10#$month + 2)) + if [ $month -gt 12 ]; then + month=$((month - 12)) + year=$((year + 1)) + fi + + next_release=$(printf "%02d.%02d" $year $month) + echo "Next release is $next_release" + echo "BASE=branch-$next_release" >> $GITHUB_ENV fi - next_release=$(printf "%02d.%02d" $year $month) - echo "Next release is $next_release" - echo "target_branch=branch-$next_release" >> $GITHUB_ENV + - name: Check for existing v*.0 tag + run: | + branch="${{ inputs.branch }}" + if [[ "$branch" == release/* ]]; then + version="${branch#release/}" + elif [[ "$branch" == branch-* ]]; then + version="${branch#branch-}" + else + echo "Unsupported branch ${{ inputs.branch }}..." + exit 1 + fi + tag_name="v${version}.0" + echo "Checking for tag $tag_name..." + + CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token ${{ secrets.token }}" \ + https://api.github.com/repos/${{ inputs.owner }}/${{ inputs.repo }}/git/ref/tags/${tag_name}) + if [ "$CODE" -eq 200 ]; then + echo "Tag $tag_name exists. Skipping merge..." + echo "continue_merge=false" >> $GITHUB_ENV + else + echo "Proceeding with merge..." + echo "continue_merge=true" >> $GITHUB_ENV + fi - # TODO: make this step as a shared action - name: Check if target branch exists - id: check_branch + if: env.continue_merge == 'true' run: | CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ - https://api.github.com/repos/${{ inputs.owner }}/${{ inputs.repo }}/branches/${{ env.target_branch }}) + https://api.github.com/repos/${{ inputs.owner }}/${{ inputs.repo }}/branches/${{ env.BASE }}) echo "Response code: $CODE..." if [ $CODE -eq 200 ]; then - echo "branch_exists=true" >> $GITHUB_ENV + echo "continue_merge=true" >> $GITHUB_ENV else - echo "branch_exists=false" >> $GITHUB_ENV - echo "Failed to find ${BRANCH_NAME}. Skip auto-merge..." + echo "continue_merge=false" >> $GITHUB_ENV + echo "Failed to find $BASE. Skip auto-merge..." fi + - name: prepare code base if $FILE_USE_BASE has content + uses: actions/checkout@v4 + if: ${{ inputs.file_use_base != '' && env.continue_merge == 'true' }} + with: + ref: ${{ env.HEAD }} # force to fetch from the latest upstream instead of PR ref + token: ${{ secrets.token }} + + - name: push intermediate branch for auto-merge + if: ${{ inputs.file_use_base != '' && env.continue_merge == 'true' }} + run: | + git config user.name "spark-rapids automation" + git config user.email "70000568+nvauto@users.noreply.github.com" + + git fetch origin "${HEAD}" "${BASE}" + git checkout -b ${INTERMEDIATE_HEAD} origin/${HEAD} + + # Sync the $BASE branch with the commits from the $HEAD branch, + # excluding the paths defined as $FILE_USE_BASE. + git checkout origin/${BASE} -- ${FILE_USE_BASE} + + # If any $FILE_USE_BASE is updated in the HEAD branch, + # always change it to the corresponding one from the BASE branch. + [ ! -z "$(git status --porcelain=v1 --untracked=no)" ] && \ + git commit -s -am "Auto-merge use ${BASE} versions" + git push origin ${INTERMEDIATE_HEAD} -f + + # overwrite HEAD env + echo "HEAD=$INTERMEDIATE_HEAD" >> $GITHUB_ENV + echo "DELETE_HEAD=True" >> $GITHUB_ENV + env: + INTERMEDIATE_HEAD: bot-auto-merge-${{ env.HEAD }} + FILE_USE_BASE: ${{ inputs.file_use_base }} + - name: auto-merge job - if: env.branch_exists == 'true' - uses: NVIDIA/spark-rapids-common/auto-merge@main + if: env.continue_merge == 'true' + uses: NVIDIA/spark-rapids-common/action-helper@main + with: + operator: auto-merge env: OWNER: ${{ inputs.owner }} - REPO_NAME: ${{ inputs.repo }} - HEAD: ${{ inputs.branch }} - BASE: ${{ env.target_branch }} - AUTOMERGE_TOKEN: ${{ secrets.token }} + REPO: ${{ inputs.repo }} + HEAD: ${{ env.HEAD }} + BASE: ${{ env.BASE }} + TOKEN: ${{ secrets.token }} + DELETE_HEAD: ${{ env.DELETE_HEAD }} diff --git a/action-helper/Dockerfile b/action-helper/Dockerfile new file mode 100755 index 0000000..c8abc91 --- /dev/null +++ b/action-helper/Dockerfile @@ -0,0 +1,23 @@ +# Copyright (c) 2025, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FROM python:alpine + +WORKDIR / +COPY python /python +COPY entrypoint.sh . +RUN chmod -R +x /python /entrypoint.sh +RUN pip install requests + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/action-helper/action.yml b/action-helper/action.yml new file mode 100755 index 0000000..8b94967 --- /dev/null +++ b/action-helper/action.yml @@ -0,0 +1,25 @@ +# Copyright (c) 2025, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: 'action helper' +description: 'helper for github-related operations' +inputs: + operator: + required: true + description: 'specify operator, e.g. auto-merge' +runs: + using: 'docker' + image: 'Dockerfile' + args: + - ${{ inputs.operator }} diff --git a/action-helper/entrypoint.sh b/action-helper/entrypoint.sh new file mode 100755 index 0000000..935a9f6 --- /dev/null +++ b/action-helper/entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh -l +# +# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -e + +if [ $# -ne 1 ]; then + echo "ERROR: invalid number of parameters, should be exact one" + exit 1 +fi + +case $1 in + + auto-merge) + /python/auto-merge --delete_head="${DELETE_HEAD}" + ;; + + *) + echo "ERROR: unknown parameter: $1" + ;; +esac diff --git a/action-helper/python/auto-merge b/action-helper/python/auto-merge new file mode 100755 index 0000000..3140199 --- /dev/null +++ b/action-helper/python/auto-merge @@ -0,0 +1,69 @@ +#!/usr/bin/env python + +# Copyright (c) 2025, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +from argparse import ArgumentParser + +from utils import EnvDefault, PullRequest, strtobool + + +def main(): + parser = ArgumentParser(description="Automerge") + parser.add_argument("--owner", action=EnvDefault, env="OWNER", + help="github token, will try use env OWNER if empty") + parser.add_argument("--repo", action=EnvDefault, env="REPO", + help="repo name, will try use env REPO if empty") + parser.add_argument("--head", action=EnvDefault, env="HEAD", + help="HEAD ref, will try use env HEAD if empty") + parser.add_argument("--base", action=EnvDefault, env="BASE", + help="Base ref, will try use env BASE if empty") + parser.add_argument("--token", action=EnvDefault, env="TOKEN", + help="github token, will try use env TOKEN if empty") + parser.add_argument("--delete_head", default=False, type=lambda x: bool(strtobool(x)), + help="if delete HEAD branch after auto-merge") + args = parser.parse_args() + + pr = PullRequest(head_owner=args.owner, head=args.head, head_token=args.token, + base_owner=args.owner, repo=args.repo, base=args.base, base_token=args.token) + try: + if exist := pr.get_open(): + number = exist[0].get('number') + sha = exist[0].get('head').get('sha') + else: + params = { + # head share the same owner/repo with base in auto-merge + 'title': f"[auto-merge] {pr.head} to {pr.base} [skip ci] [bot]", + 'head': f"{pr.head_owner}:{pr.head}", + 'base': pr.base, + 'body': f"auto-merge triggered by github actions on `{pr.head}` to " + f"create a PR keeping `{pr.base}` up-to-date. " + "If this PR is unable to be merged due to conflicts, " + "it will remain open until manually fix.", + 'maintainer_can_modify': True + } + number, sha, term = pr.create(params) + if term: + sys.exit(0) + pr.auto_merge(number, sha) + if args.delete_head: + pr.delete_head() + except Exception as e: + print(e) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/action-helper/python/utils.py b/action-helper/python/utils.py new file mode 100755 index 0000000..2d2c914 --- /dev/null +++ b/action-helper/python/utils.py @@ -0,0 +1,183 @@ +# Copyright (c) 2025, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +import os + +import requests + +# constants +API_URL = 'https://api.github.com' + + +class PullRequest: + """Pull Request class""" + + def __init__(self, + head_owner, head, head_token, + base_owner, repo, base, base_token): + self.head_owner = head_owner + self.head = head + self.base_owner = base_owner + self.repo = repo + self.base = base + self.pulls_url = f'{API_URL}/repos/{self.base_owner}/{self.repo}/pulls' + self._head_auth_headers = { + 'Accept': 'application/vnd.github.v3+json', + 'Authorization': f"token {head_token}" + } + self._base_auth_headers = { + 'Accept': 'application/vnd.github.v3+json', + 'Authorization': f"token {base_token}" + } + + def get_open(self): + """get open pull request if existed""" + params = { + 'state': 'open', + 'head': f"{self.head_owner}:{self.head}", + 'base': self.base, + } + r = requests.get(self.pulls_url, headers=self._base_auth_headers, params=params) + if r.status_code == 200: + return r.json() + if r.status_code == 304: + return None + # FAILURE + print('FAILURE - list PR') + print(f'status code: {r.status_code}') + raise Exception(f"Failed to list PR: {r.json()}") + + def create(self, params): + """create a pull request""" + # the token here must have write access to head owner/repo + r = requests.post(self.pulls_url, headers=self._head_auth_headers, json=params) + if r.status_code == 201: + print('SUCCESS - create PR') + pull = r.json() + number = str(pull['number']) + sha = str(pull['head']['sha']) + return number, sha, False + if r.status_code == 422: # early-terminate if no commits between HEAD and BASE + print('SUCCESS - No commits') + print(r.json()) + return '', '', True + # FAILURE + print('FAILURE - create PR') + print(f'status code: {r.status_code}') + raise Exception(f"Failed to create PR: {r.json()}") + + def merge(self, number, params): + """merge a pull request""" + # the token here must have write access to base owner/repo + url = f'{self.pulls_url}/{number}/merge' + return requests.put(url, headers=self._head_auth_headers, json=params) + + def auto_merge(self, number, sha, merge_method='merge'): + """merge a auto-merge pull request""" + params = { + 'sha': sha, + 'merge_method': merge_method, + } + r = self.merge(number, params) + if r.status_code == 200: + self.comment(number, '**SUCCESS** - auto-merge') + print('SUCCESS - auto-merge') + return + else: + print('FAILURE - auto-merge') + self.comment(number=number, content=f"""**FAILURE** - Unable to auto-merge. Manual operation is required. +``` +{r.json()} +``` + +Please use the following steps to fix the merge conflicts manually: +``` +# Assume upstream is {self.base_owner}/{self.repo} remote +git fetch upstream {self.head} {self.base} +git checkout -b fix-auto-merge-conflict-{number} upstream/{self.base} +git merge upstream/{self.head} +# Fix any merge conflicts caused by this merge +git commit -am "Merge {self.head} into {self.base}" +git push fix-auto-merge-conflict-{number} +# Open a PR targets {self.base_owner}/{self.repo} {self.base} +``` +**IMPORTANT:** Before merging this PR, be sure to change the merging strategy to `Create a merge commit` (repo admin only). + +Once this PR is merged, the auto-merge PR should automatically be closed since it contains the same commit hashes +""") + print(f'status code: {r.status_code}') + raise Exception(f"Failed to auto-merge PR: {r.json()}") + + def comment(self, number, content): + """comment in a pull request""" + url = f'{API_URL}/repos/{self.base_owner}/{self.repo}/issues/{number}/comments' + params = { + 'body': content + } + r = requests.post(url, headers=self._base_auth_headers, json=params) + if r.status_code == 201: + print('SUCCESS - create comment') + else: + print('FAILURE - create comment') + print(f'status code: {r.status_code}') + raise Exception(f"Failed to create comment: {r.json()}") + + def delete_branch(self, owner, branch): + """delete a branch""" + url = f'{API_URL}/repos/{owner}/{self.repo}/git/refs/heads/{branch}' + r = requests.delete(url, headers=self._base_auth_headers) + if r.status_code == 204: + print(f'SUCCESS - delete {branch}') + else: + print(f'FAILURE - delete {branch}') + print(f'status code: {r.status_code}') + raise Exception(f"Failed to delete {branch}: {r.json()}") + + def delete_head(self): + """delete the HEAD branch in a pull request""" + return self.delete_branch(self.head_owner, self.head) + + +class EnvDefault(argparse.Action): + """EnvDefault argparse action class""" + + def __init__(self, env, default=None, required=True, **kwargs): + if not default and env: + if env in os.environ: + default = os.environ[env] + if required and default: + required = False + super(EnvDefault, self).__init__(default=default, required=required, **kwargs) + + def __call__(self, parser, namespace, values, option_string=None): + setattr(namespace, self.dest, values) + + +def strtobool(val): + """Convert a string representation of truth to true (1) or false (0). + + this function is copied from distutils.util to avoid deprecation warning https://www.python.org/dev/peps/pep-0632/ + + True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values + are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if + 'val' is anything else. + """ + val = val.lower() + if val in ('y', 'yes', 't', 'true', 'on', '1'): + return 1 + elif val in ('n', 'no', 'f', 'false', 'off', '0'): + return 0 + else: + raise ValueError("invalid truth value %r" % (val,))