diff --git a/doc/source/changelog/1168.added.md b/doc/source/changelog/1168.added.md new file mode 100644 index 000000000..d79abf428 --- /dev/null +++ b/doc/source/changelog/1168.added.md @@ -0,0 +1 @@ +\`hk-migrate-fork-pr\` action diff --git a/doc/source/housekeeping-actions/examples/hk-migrate-fork-pr-basic.yml b/doc/source/housekeeping-actions/examples/hk-migrate-fork-pr-basic.yml new file mode 100644 index 000000000..4d81bc333 --- /dev/null +++ b/doc/source/housekeeping-actions/examples/hk-migrate-fork-pr-basic.yml @@ -0,0 +1,23 @@ +hk-migrate-fork-pr: + name: "Migrate fork PR to main repository" + runs-on: ubuntu-latest + # Only runs on issue comments + if: | + github.event_name == 'issue_comment' && + github.event.issue.pull_request != null && + (contains(github.event.comment.body, '@pyansys-ci-bot migrate') || + contains(github.event.comment.body, '@pyansys-ci-bot sync')) + permissions: + contents: write + pull-requests: write + steps: + - name: "Migrate fork PR" + uses: ansys/actions/hk-migrate-fork-pr@{{ version }} + with: + pr-number: ${{ '{{ github.event.issue.number }}' }} + comment-id: ${{ '{{ github.event.comment.id }}' }} + user-triggering: ${{ '{{ github.event.comment.user.login }}' }} + github-token: ${{ '{{ secrets.PYANSYS_CI_BOT_TOKEN }}' }} + bot-username: ${{ '{{ secrets.PYANSYS_CI_BOT_USERNAME }}' }} + bot-email: ${{ '{{ secrets.PYANSYS_CI_BOT_EMAIL }}' }} + team-slug: 'pyansys-maintainers' diff --git a/doc/source/housekeeping-actions/examples/hk-migrate-fork-pr-with-conflict-resolution.yml b/doc/source/housekeeping-actions/examples/hk-migrate-fork-pr-with-conflict-resolution.yml new file mode 100644 index 000000000..86c10ee9d --- /dev/null +++ b/doc/source/housekeeping-actions/examples/hk-migrate-fork-pr-with-conflict-resolution.yml @@ -0,0 +1,25 @@ +hk-migrate-fork-pr-with-conflicts: + name: "Migrate fork PR with conflict resolution" + runs-on: ubuntu-latest + if: | + github.event_name == 'issue_comment' && + github.event.issue.pull_request != null && + (contains(github.event.comment.body, '@pyansys-ci-bot migrate') || + contains(github.event.comment.body, '@pyansys-ci-bot sync')) + permissions: + contents: write + pull-requests: write + steps: + - name: "Migrate fork PR with explicit conflict mode" + uses: ansys/actions/hk-migrate-fork-pr@{{ version }} + with: + pr-number: ${{ '{{ github.event.issue.number }}' }} + comment-id: ${{ '{{ github.event.comment.id }}' }} + user-triggering: ${{ '{{ github.event.comment.user.login }}' }} + github-token: ${{ '{{ secrets.PYANSYS_CI_BOT_TOKEN }}' }} + bot-username: ${{ '{{ secrets.PYANSYS_CI_BOT_USERNAME }}' }} + bot-email: ${{ '{{ secrets.PYANSYS_CI_BOT_EMAIL }}' }} + team-slug: 'pyansys-maintainers' + # Override conflict resolution mode + # Options: 'auto' (fail on conflicts), 'theirs' (use fork branch), 'ours' (use main branch) + conflict-mode: 'theirs' diff --git a/doc/source/housekeeping-actions/index.rst b/doc/source/housekeeping-actions/index.rst index b5c9f1d4b..e7be5c0db 100644 --- a/doc/source/housekeeping-actions/index.rst +++ b/doc/source/housekeeping-actions/index.rst @@ -20,3 +20,9 @@ Auto-merge pull requests .. jinja:: hk-automerge-prs :file: _templates/action.rst.jinja + +Migrate fork pull requests +--------------------------- + +.. jinja:: hk-migrate-fork-pr + :file: _templates/action.rst.jinja diff --git a/hk-migrate-fork-pr/action.yml b/hk-migrate-fork-pr/action.yml new file mode 100644 index 000000000..e010bebc5 --- /dev/null +++ b/hk-migrate-fork-pr/action.yml @@ -0,0 +1,662 @@ +# Copyright (C) 2022 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +name: | + Migrate fork PR to main repository + +description: | + Migrates pull requests from forks to branches within the main repository, + enabling workflows that require repository secrets to run. This action + handles team membership verification, conflict resolution, and automated PR + creation. + + .. note:: + This action is designed to be called from workflows triggered by issue + comments or workflow dispatch events. + + .. important:: **Required GitHub Permissions** + + - **contents**: ``write`` - Required to push migration branches to the main repository + - **pull-requests**: ``write`` - Required to create pull requests, add comments, and manage reactions + + Additionally, the provided ``github-token`` must have ``read:org`` scope + to check team membership for authorization. + +inputs: + + # Required inputs + + pr-number: + description: | + The pull request number to migrate. This should be the PR number from + the fork that needs to be migrated to the main repository. + required: true + type: string + + comment-id: + description: | + The comment ID that triggered the migration. Used for adding reactions + to indicate success or failure. + required: true + type: string + + user-triggering: + description: | + The GitHub username of the user triggering the migration. This user + will be verified for team membership before proceeding. + required: true + type: string + + github-token: + description: | + GitHub token with permissions to create branches, push code, create + pull requests, manage comments/reactions, and check team membership. + Typically a bot token with ``contents: write``, ``pull-requests: write``, + and ``read:org`` permissions. + required: true + type: string + + bot-username: + description: | + The username of the bot that will be used for git commits during the + migration process. + required: true + type: string + + bot-email: + description: | + The email address of the bot that will be used for git commits during + the migration process. + required: true + type: string + + team-slug: + description: | + The slug of the GitHub team that has permission to trigger migrations. + For example, ``pymapdl-maintainers`` or ``pyansys-core``. + required: true + type: string + + # Optional inputs + + comment-body: + description: | + The body of the comment that triggered the migration. Used to detect + conflict resolution mode. Should contain ``@pyansys-ci-bot migrate`` + or ``@pyansys-ci-bot sync`` with optional ``theirs`` or ``ours`` + modifiers. + required: false + type: string + default: '@pyansys-ci-bot migrate' + + organization: + description: | + The GitHub organization name. Defaults to ``ansys``. + required: false + type: string + default: 'ansys' + + repository: + description: | + The repository name (without organization). If not provided, it will be + inferred from the GitHub context. + required: false + type: string + default: '' + + conflict-mode: + description: | + Override for conflict resolution mode. Valid values are ``auto``, + ``theirs``, or ``ours``. If not provided, the mode will be detected + from the comment body. Use ``theirs`` to accept changes from the fork + branch, ``ours`` to keep changes from the main branch, or ``auto`` to + exit on conflicts. + required: false + type: string + default: 'auto' + +outputs: + + migration-successful: + description: | + Boolean indicating whether the migration completed successfully. + value: ${{ steps.finalize.outputs.migration-successful }} + + new-pr-number: + description: | + The pull request number of the newly created PR in the main repository. + Empty if migration was skipped or failed. + value: ${{ steps.create-pr.outputs.new-pr-number }} + + migration-branch: + description: | + The name of the migration branch created in the main repository. + value: ${{ steps.pr-details.outputs.migration-branch }} + + skip-migration: + description: | + Boolean indicating whether the migration was skipped (e.g., user not + authorized, PR already in main repo). + value: ${{ steps.finalize.outputs.skip-migration }} + +runs: + using: "composite" + steps: + + - name: "Log start of migration" + uses: ansys/actions/_logging@main + with: + level: "INFO" + message: | + Starting fork PR migration for PR #${{ inputs.pr-number }} + Triggered by: ${{ inputs.user-triggering }} + + - name: "Setup configuration and detect conflict mode" + id: setup + shell: bash + env: + COMMENT_BODY: ${{ inputs.comment-body }} + CONFLICT_MODE_INPUT: ${{ inputs.conflict-mode }} + REPOSITORY: ${{ inputs.repository }} + run: | + # Set repository name + if [ -z "${REPOSITORY}" ]; then + # Extract from GITHUB_REPOSITORY (format: org/repo) + REPO_NAME="${GITHUB_REPOSITORY#*/}" + else + REPO_NAME="${REPOSITORY}" + fi + echo "repository=${REPO_NAME}" >> "$GITHUB_OUTPUT" + + # Detect conflict resolution mode + # 1. auto - exits on conflicts (default) + # 2. theirs - resolves conflicts by taking changes from the head branch + # 3. ours - resolves conflicts by taking changes from the base branch + + MODE='' + if [ "${CONFLICT_MODE_INPUT}" != "auto" ]; then + # Use explicit override + if [ "${CONFLICT_MODE_INPUT}" == "theirs" ]; then + MODE='--theirs' + echo "Using explicit conflict mode: theirs" + elif [ "${CONFLICT_MODE_INPUT}" == "ours" ]; then + MODE='--ours' + echo "Using explicit conflict mode: ours" + fi + else + # Detect from comment body + if echo "${COMMENT_BODY}" | grep -q "pyansys-ci-bot sync theirs\|pyansys-ci-bot migrate theirs"; then + echo "Resolving conflicts by taking 'theirs' changes" + MODE='--theirs' + elif echo "${COMMENT_BODY}" | grep -q "pyansys-ci-bot sync ours\|pyansys-ci-bot migrate ours"; then + echo "Resolving conflicts by taking 'ours' changes" + MODE='--ours' + else + echo "No specific sync mode provided, defaulting to 'auto' which will exit if there are conflicts." + fi + fi + + echo "mode=${MODE}" >> "$GITHUB_OUTPUT" + + - name: "Check team membership" + id: check-membership + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + TEAM_SLUG: ${{ inputs.team-slug }} + ORGANIZATION: ${{ inputs.organization }} + USER_TRIGGERING: ${{ inputs.user-triggering }} + run: | + # Check if user is a member of the required team + set +e + MEMBERSHIP=$(gh api \ + "orgs/${ORGANIZATION}/teams/${TEAM_SLUG}/memberships/${USER_TRIGGERING}" \ + --jq '{state: .state, role: .role}' 2>&1) + API_EXIT=$? + set -e + + if [ $API_EXIT -ne 0 ]; then + echo "::error::Error fetching team membership: ${MEMBERSHIP}" + echo "is_member=false" >> "$GITHUB_OUTPUT" + echo "continue=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + STATE=$(echo "${MEMBERSHIP}" | jq -r '.state') + ROLE=$(echo "${MEMBERSHIP}" | jq -r '.role') + + if [ "${STATE}" = "active" ] && { [ "${ROLE}" = "member" ] || [ "${ROLE}" = "maintainer" ]; }; then + echo "User ${USER_TRIGGERING} is a ${ROLE} of team ${TEAM_SLUG}" + echo "is_member=true" >> "$GITHUB_OUTPUT" + echo "continue=true" >> "$GITHUB_OUTPUT" + else + echo "::warning::User ${USER_TRIGGERING} is not an active member of team ${TEAM_SLUG}" + echo "is_member=false" >> "$GITHUB_OUTPUT" + echo "continue=false" >> "$GITHUB_OUTPUT" + fi + + - name: "Delete previous reactions" + if: always() + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + BOT_USERNAME: ${{ inputs.bot-username }} + ORGANIZATION: ${{ inputs.organization }} + REPOSITORY: ${{ steps.setup.outputs.repository }} + COMMENT_ID: ${{ inputs.comment-id }} + run: | + # List all reactions on the triggering comment + set +e + REACTIONS=$(gh api \ + "repos/${ORGANIZATION}/${REPOSITORY}/issues/comments/${COMMENT_ID}/reactions" \ + --jq '.[] | select(.user.login == env.BOT_USERNAME) | {id: .id, content: .content}' 2>&1) + API_EXIT=$? + set -e + + if [ $API_EXIT -ne 0 ]; then + echo "::warning::Error listing reactions: ${REACTIONS}" + exit 0 + fi + + if [ -z "${REACTIONS}" ]; then + echo "No bot reactions found for comment ${COMMENT_ID}" + exit 0 + fi + + # Delete each bot reaction + echo "${REACTIONS}" | jq -c '.' | while IFS= read -r reaction; do + REACTION_ID=$(echo "${reaction}" | jq -r '.id') + CONTENT=$(echo "${reaction}" | jq -r '.content') + set +e + gh api -X DELETE \ + "repos/${ORGANIZATION}/${REPOSITORY}/issues/comments/${COMMENT_ID}/reactions/${REACTION_ID}" 2>&1 + set -e + echo "Deleted reaction ${CONTENT} (id: ${REACTION_ID}) from ${BOT_USERNAME}" + done + + - name: "React to comment based on authorization" + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + TEAM_SLUG: ${{ inputs.team-slug }} + CONTINUE: ${{ steps.check-membership.outputs.continue }} + ORGANIZATION: ${{ inputs.organization }} + REPOSITORY: ${{ steps.setup.outputs.repository }} + COMMENT_ID: ${{ inputs.comment-id }} + PR_NUMBER: ${{ inputs.pr-number }} + run: | + if [ "${CONTINUE}" = "true" ]; then + echo "User is authorized. Adding positive reaction." + gh api -X POST \ + "repos/${ORGANIZATION}/${REPOSITORY}/issues/comments/${COMMENT_ID}/reactions" \ + -f content='+1' > /dev/null + else + echo "::warning::User is NOT authorized to migrate PRs!" + + # React negatively + gh api -X POST \ + "repos/${ORGANIZATION}/${REPOSITORY}/issues/comments/${COMMENT_ID}/reactions" \ + -f content='-1' > /dev/null + + # Create a comment to notify the user + BODY=$(cat <> "$GITHUB_OUTPUT" + + # React with confused emoji + gh api -X POST \ + "repos/${ORGANIZATION}/${REPOSITORY}/issues/comments/${COMMENT_ID}/reactions" \ + -f content='confused' > /dev/null + + # Notify user + NOTIFY_BODY=$(cat <> "$GITHUB_OUTPUT" + + # Use delimiter for multiline values (title and body may contain special chars) + { + echo "pr-title<> "$GITHUB_OUTPUT" + + { + echo "pr-body<> "$GITHUB_OUTPUT" + + echo "PR Head: ${HEAD_REPO_FULL}/${HEAD_REF}" + echo "PR Base: ${ORGANIZATION}/${REPOSITORY}/${BASE_REF}" + echo "Migration branch: migration/pr-${PR_NUMBER}" + + - name: "Checkout repository" + if: steps.check-membership.outputs.continue == 'true' && steps.pr-details.outputs.skip != 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + token: ${{ inputs.github-token }} + fetch-depth: 0 + persist-credentials: true # zizmor: ignore[artipacked] + + - name: "Clone head repo and merge with main" + if: steps.check-membership.outputs.continue == 'true' && steps.pr-details.outputs.skip != 'true' + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + BOT_USERNAME: ${{ inputs.bot-username }} + BOT_EMAIL: ${{ inputs.bot-email }} + PR_HEAD_REPO: ${{ steps.pr-details.outputs.pr-head-repo }} + PR_HEAD_BRANCH: ${{ steps.pr-details.outputs.pr-head-branch }} + PR_BASE_BRANCH: ${{ steps.pr-details.outputs.pr-base-branch }} + ORGANIZATION: ${{ inputs.organization }} + REPOSITORY: ${{ steps.setup.outputs.repository }} + MODE: ${{ steps.setup.outputs.mode }} + run: | + # Configure git + git config user.name "${BOT_USERNAME}" + git config user.email "${BOT_EMAIL}" + + # Add head repo as remote + echo "Adding head repo as remote: ${PR_HEAD_REPO}" + git remote add head_repo https://x-access-token:${GITHUB_TOKEN}@github.com/${PR_HEAD_REPO}.git + + echo "Fetching '${PR_HEAD_BRANCH}' branch from '${PR_HEAD_REPO}'" + git fetch head_repo ${PR_HEAD_BRANCH} + + echo "Checking out '${PR_BASE_BRANCH}' branch from 'head_repo/${PR_HEAD_BRANCH}'" + git checkout -b ${PR_BASE_BRANCH} head_repo/${PR_HEAD_BRANCH} + + echo "Pulling '${PR_HEAD_BRANCH}' from '${PR_HEAD_REPO}'" + git pull head_repo ${PR_HEAD_BRANCH} || true + + echo "Merging 'main' branch into '${PR_BASE_BRANCH}'" + git merge origin/main || true + + # Check for merge conflicts + CONFLICTS=$(git ls-files -u | wc -l) + echo "Number of conflicting files: ${CONFLICTS}" + + if [[ "$CONFLICTS" -gt 0 ]]; then + if [[ -n "${MODE}" ]]; then + echo "::warning::Conflicts detected. Attempting to resolve using mode ${MODE}." + + # Show conflicting files + echo "Conflicting files:" + git status + echo "" + + # Resolve conflicts + echo "Resolving conflicts by taking '${MODE}' changes" + git checkout ${MODE} . + git add . + + # Verify if conflicts are resolved + REMAINING_CONFLICTS=$(git ls-files -u | wc -l) + if [ "$REMAINING_CONFLICTS" -gt 0 ]; then + echo "::error::Conflicts remain after resolution. Aborting." + exit 1 + fi + + # Continue the merge + git -c core.editor=true merge --continue || { echo "::error::Merge failed. Aborting."; exit 1; } + else + echo "::error::Merge conflicts detected and no resolution mode specified. Aborting." + git status + exit 1 + fi + else + echo "No merge conflicts detected." + fi + + echo "Pushing changes to '${ORGANIZATION}/${REPOSITORY}' repo" + git push origin ${PR_BASE_BRANCH} --force-with-lease || \ + (git fetch --all && git push origin ${PR_BASE_BRANCH} --force-with-lease) || \ + { echo "::error::Push failed. Aborting."; exit 1; } + + # If using 'ours' mode, also push to the head repo + if [[ "${MODE}" == "--ours" ]]; then + echo "Sync mode is 'ours'. Pushing to head_repo/${PR_HEAD_BRANCH} with 'ours' changes" + git push head_repo ${PR_BASE_BRANCH}:${PR_HEAD_BRANCH} --force-with-lease || \ + { echo "::error::Push to head_repo/${PR_HEAD_BRANCH} failed. Aborting."; exit 1; } + fi + + - name: "Create pull request" + if: steps.check-membership.outputs.continue == 'true' && steps.pr-details.outputs.skip != 'true' + id: create-pr + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + PR_NUMBER: ${{ inputs.pr-number }} + PR_BASE_BRANCH: ${{ steps.pr-details.outputs.pr-base-branch }} + PR_TITLE: ${{ steps.pr-details.outputs.pr-title }} + PR_BODY: ${{ steps.pr-details.outputs.pr-body }} + PR_AUTHOR: ${{ steps.pr-details.outputs.pr-author }} + USER_TRIGGERING: ${{ inputs.user-triggering }} + ORGANIZATION: ${{ inputs.organization }} + REPOSITORY: ${{ steps.setup.outputs.repository }} + COMMENT_ID: ${{ inputs.comment-id }} + run: | + PR_BASE="${PR_BASE_BRANCH}" + + echo "Checking for existing PRs with head branch ${PR_BASE}..." + + # Check if PR already exists + EXISTING_PR=$(gh pr list \ + --repo "${ORGANIZATION}/${REPOSITORY}" \ + --head "${PR_BASE}" \ + --state open \ + --json number \ + --jq '.[0].number // empty') + + if [ -n "${EXISTING_PR}" ]; then + echo "PR already exists for branch ${PR_BASE}: #${EXISTING_PR}. Skipping creation." + echo "new-pr-number=${EXISTING_PR}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Build the new PR body + NEW_PR_BODY=$(cat <> "$GITHUB_OUTPUT" + + # Assign PR to triggering user and original author + echo "Assigning PR to: ${USER_TRIGGERING} and ${PR_AUTHOR}" + set +e + gh pr edit "${NEW_PR_NUM}" \ + --repo "${ORGANIZATION}/${REPOSITORY}" \ + --add-assignee "${USER_TRIGGERING},${PR_AUTHOR}" 2>&1 || \ + echo "::warning::Failed to assign PR" + set -e + + # React with rocket to the triggering comment + gh api -X POST \ + "repos/${ORGANIZATION}/${REPOSITORY}/issues/comments/${COMMENT_ID}/reactions" \ + -f content='rocket' > /dev/null + + # Create success comment + SUCCESS_BODY=$(cat < /dev/null + + # Create error comment + RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + + ERROR_BODY=$(cat <> $GITHUB_OUTPUT + echo "skip-migration=false" >> $GITHUB_OUTPUT + elif [ "${CONTINUE}" == "false" ] || [ "${SKIP}" == "true" ]; then + echo "migration-successful=false" >> $GITHUB_OUTPUT + echo "skip-migration=true" >> $GITHUB_OUTPUT + else + echo "migration-successful=false" >> $GITHUB_OUTPUT + echo "skip-migration=false" >> $GITHUB_OUTPUT + fi + + - name: "Log completion" + if: always() + uses: ansys/actions/_logging@main + with: + level: "INFO" + message: | + Migration process completed for PR #${{ inputs.pr-number }} + Status: ${{ steps.finalize.outputs.migration-successful == 'true' && 'Success' || 'Failed or Skipped' }}