diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 00000000000..9af84e3d876 --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,494 @@ +name: Backport and Forwardport PRs + +on: + pull_request: + types: [closed] + workflow_dispatch: + inputs: + pr_number: + description: 'Pull Request number to backport/forwardport' + required: true + type: number + dry_run: + description: 'Dry run mode - show what would happen without making changes' + required: false + type: boolean + default: true + +permissions: + contents: write + pull-requests: write + +jobs: + backport: + name: Backport/Forwardport PR + runs-on: ubuntu-latest + steps: + - uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Get GitHub App User ID + id: get-user-id + run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + + - name: Configure Git + run: | + git config --global user.name '${{ steps.app-token.outputs.app-slug }}[bot]' + git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com' + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Perform backport/forwardport + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + # Determine PR number and dry-run mode based on trigger type + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + PR_NUMBER="${{ inputs.pr_number }}" + DRY_RUN="${{ inputs.dry_run }}" + else + PR_NUMBER="${{ github.event.pull_request.number }}" + DRY_RUN="false" + fi + + if [ "$DRY_RUN" = "true" ]; then + echo "🔍 DRY RUN MODE ENABLED - No changes will be made" + echo "==================================================" + fi + + # Fetch PR details from API + PR_DATA=$(gh pr view "$PR_NUMBER" --json number,title,author,mergeCommit,state) + + # Extract PR details + PR_TITLE=$(echo "$PR_DATA" | jq -r '.title') + PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login') + MERGE_COMMIT_SHA=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid // empty') + PR_STATE=$(echo "$PR_DATA" | jq -r '.state') + + # Validate PR is merged + if [ "$PR_STATE" != "MERGED" ]; then + echo "Error: PR #$PR_NUMBER is not merged (state: $PR_STATE). Cannot backport unmerged PRs." + exit 1 + fi + + echo "Processing PR #$PR_NUMBER: $PR_TITLE" + echo "Author: $PR_AUTHOR" + echo "Merge commit: $MERGE_COMMIT_SHA" + + # Get all labels from the PR + LABELS=$(gh pr view "$PR_NUMBER" --json labels --jq '.labels[].name') + + # Extract backport branches + BACKPORT_BRANCHES=$(echo "$LABELS" | grep "^Backport to: " | sed 's/^Backport to: //' || true) + FORWARDPORT_BRANCHES=$(echo "$LABELS" | grep "^Forwardport to: " | sed 's/^Forwardport to: //' || true) + + # Extract other labels (excluding backport/forwardport labels) + OTHER_LABELS=$(echo "$LABELS" | grep -v "^Backport to: " | grep -v "^Forwardport to: " | jq -R -s -c 'split("\n") | map(select(length > 0))' || echo '[]') + + if [ -n "$BACKPORT_BRANCHES" ]; then + echo "Will backport PR #$PR_NUMBER to branches: $BACKPORT_BRANCHES" + fi + if [ -n "$FORWARDPORT_BRANCHES" ]; then + echo "Will forwardport PR #$PR_NUMBER to branches: $FORWARDPORT_BRANCHES" + fi + + # Backport processing + + if [ "$DRY_RUN" = "true" ]; then + echo "🔍 DRY RUN: Backport Processing" + echo "================================" + fi + + # Process each backport branch + while IFS= read -r BRANCH; do + [ -z "$BRANCH" ] && continue + + echo "Processing backport to branch: $BRANCH" + + PORT_TYPE="backport" + NEW_BRANCH="${PORT_TYPE}-${PR_NUMBER}-to-${BRANCH}" + + # Fetch the target branch + git fetch origin "$BRANCH:$BRANCH" || { + echo "Error: Failed to fetch branch $BRANCH" + continue + } + + # Create and checkout new branch from target branch + git checkout -b "$NEW_BRANCH" "$BRANCH" || { + echo "Error: Failed to create branch $NEW_BRANCH" + continue + } + + # Attempt cherry-pick + CONFLICT=false + if ! git cherry-pick -m 1 "$MERGE_COMMIT_SHA" 2>&1; then + # Check if there are conflicts + if git status | grep -q "Unmerged paths\|both modified"; then + echo "Conflicts detected during cherry-pick" + CONFLICT=true + + # Stage all changes + git add . + + # Commit with conflict message + git commit -m "Cherry-pick $MERGE_COMMIT_SHA with conflicts" || { + echo "Error: Failed to commit conflicts" + git checkout main + git branch -D "$NEW_BRANCH" 2>/dev/null || true + continue + } + else + echo "Error: Cherry-pick failed with non-conflict error" + git cherry-pick --abort 2>/dev/null || true + git checkout main + git branch -D "$NEW_BRANCH" 2>/dev/null || true + continue + fi + else + # Cherry-pick succeeded, amend to update author + git commit --amend --no-edit --reset-author || { + echo "Error: Failed to amend commit" + git checkout main + git branch -D "$NEW_BRANCH" 2>/dev/null || true + continue + } + fi + + # Push the new branch + if [ "$DRY_RUN" = "true" ]; then + echo " [DRY RUN] Would push branch: $NEW_BRANCH" + echo " Command: git push -f origin $NEW_BRANCH" + NEW_PR_NUMBER="" + else + git push -f origin "$NEW_BRANCH" || { + echo "Error: Failed to push branch $NEW_BRANCH" + git checkout main + git branch -D "$NEW_BRANCH" 2>/dev/null || true + continue + } + + # Create PR body + PR_BODY="## Description"$'\n'"This is a $PORT_TYPE of #${PR_NUMBER}" + + # Determine if PR should be draft + DRAFT_FLAG="" + if [ "$CONFLICT" = true ]; then + DRAFT_FLAG="--draft" + fi + + # Build labels JSON array from OTHER_LABELS and add port type specific labels + if [ "$CONFLICT" = true ]; then + LABELS_JSON=$(echo "$OTHER_LABELS" | jq -c '. + ["Merge Conflict", "Skip CI", "Backport"]') + else + LABELS_JSON=$(echo "$OTHER_LABELS" | jq -c '. + ["Backport"]') + fi + + # Convert JSON array to comma-separated list for gh pr create --label + LABELS_LIST=$(echo "$LABELS_JSON" | jq -r 'join(",")') + + echo " Creating pull request..." + # Create the pull request with labels (returns URL like https://github.com/owner/repo/pull/123) + PR_URL=$(gh pr create \ + --title "[$BRANCH] $PR_TITLE (#$PR_NUMBER)" \ + --body "$PR_BODY" \ + --base "$BRANCH" \ + --head "$NEW_BRANCH" \ + --label "$LABELS_LIST" \ + $DRAFT_FLAG 2>&1) + + if [ $? -ne 0 ] || [ -z "$PR_URL" ]; then + echo "Error: Failed to create PR for branch $NEW_BRANCH" + echo "$PR_URL" + git checkout main + continue + fi + + # Extract PR number from URL using gh pr view + NEW_PR_NUMBER=$(gh pr view "$PR_URL" --json number --jq '.number') + + echo "Created backport PR #$NEW_PR_NUMBER ($PR_URL)" + fi + + # Build labels JSON array for dry-run display + if [ "$CONFLICT" = true ]; then + LABELS_JSON=$(echo "$OTHER_LABELS" | jq -c '. + ["Merge Conflict", "Skip CI", "Backport"]') + else + LABELS_JSON=$(echo "$OTHER_LABELS" | jq -c '. + ["Backport"]') + fi + + # Display PR information (for both dry-run and real mode) + if [ "$DRY_RUN" = "true" ]; then + echo "" + echo " [DRY RUN] Would create PR with:" + echo " --------------------------------" + echo " Title: [$BRANCH] $PR_TITLE (#$PR_NUMBER)" + echo " Body: ## Description" + echo " This is a $PORT_TYPE of #${PR_NUMBER}" + echo " Base: $BRANCH" + echo " Head: $NEW_BRANCH" + if [ "$CONFLICT" = true ]; then + echo " Draft: true (due to conflicts)" + else + echo " Draft: false" + fi + echo " Repository: ${{ github.repository }}" + echo "" + echo " [DRY RUN] Would add labels:" + echo "$LABELS_JSON" | jq -r '.[]' | while read -r label; do + echo " - $label" + done + fi + + # Add conflict comment if there were conflicts + if [ "$CONFLICT" = true ]; then + CONFLICT_COMMENT="Hello @${PR_AUTHOR}, there are conflicts in this ${PORT_TYPE}."$'\n\n' + CONFLICT_COMMENT+="Please address them in order to merge this Pull Request. You can execute the snippet below to reset your branch and resolve the conflict manually."$'\n\n' + CONFLICT_COMMENT+="Make sure you replace \`origin\` by the name of the ${{ github.repository_owner }}/${{ github.event.repository.name }} remote"$'\n' + CONFLICT_COMMENT+="\`\`\`"$'\n' + CONFLICT_COMMENT+="git fetch --all"$'\n' + CONFLICT_COMMENT+="gh pr checkout ${NEW_PR_NUMBER}"$'\n' + CONFLICT_COMMENT+="git reset --hard origin/${BRANCH}"$'\n' + CONFLICT_COMMENT+="git cherry-pick -m 1 ${MERGE_COMMIT_SHA}"$'\n' + CONFLICT_COMMENT+="\`\`\`" + + if [ "$DRY_RUN" = "true" ]; then + echo "" + echo " [DRY RUN] Would add conflict resolution comment:" + echo " ------------------------------------------------" + echo "$CONFLICT_COMMENT" | sed 's/^/ /' + else + if ! OUTPUT=$(gh pr comment "$NEW_PR_NUMBER" --body "$CONFLICT_COMMENT" 2>&1); then + echo "Warning: Could not add conflict resolution comment" + echo " Error: $OUTPUT" + fi + fi + fi + + # Get reviewers from original PR and build JSON array + REVIEWERS_JSON=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/requested_reviewers" \ + --jq '[.users[].login, .teams[].slug] + ["'"$PR_AUTHOR"'"]' 2>/dev/null || echo '["'"$PR_AUTHOR"'"]') + + if [ "$DRY_RUN" = "true" ]; then + echo "" + echo " [DRY RUN] Would request reviews from:" + echo "$REVIEWERS_JSON" | jq -r '.[]' | while read -r reviewer; do + echo " - $reviewer" + done + else + # Convert JSON array to CSV and request reviewers + REVIEWERS_CSV=$(echo "$REVIEWERS_JSON" | jq -r '@csv') + if ! OUTPUT=$(gh pr edit "$NEW_PR_NUMBER" --add-reviewer "$REVIEWERS_CSV" 2>&1); then + echo "Note: Could not add some reviewers (may include PR author or have insufficient permissions)" + echo " Error: $OUTPUT" + fi + fi + + # Return to main branch for next iteration + git checkout main + done <<< "$BACKPORT_BRANCHES" + + # Forwardport processing + + if [ "$DRY_RUN" = "true" ]; then + echo "🔍 DRY RUN: Forwardport Processing" + echo "===================================" + fi + + # Process each forwardport branch + while IFS= read -r BRANCH; do + [ -z "$BRANCH" ] && continue + + echo "Processing forwardport to branch: $BRANCH" + + PORT_TYPE="forwardport" + NEW_BRANCH="${PORT_TYPE}-${PR_NUMBER}-to-${BRANCH}" + + # Fetch the target branch + git fetch origin "$BRANCH:$BRANCH" || { + echo "Error: Failed to fetch branch $BRANCH" + continue + } + + # Create and checkout new branch from target branch + git checkout -b "$NEW_BRANCH" "$BRANCH" || { + echo "Error: Failed to create branch $NEW_BRANCH" + continue + } + + # Attempt cherry-pick + CONFLICT=false + if ! git cherry-pick -m 1 "$MERGE_COMMIT_SHA" 2>&1; then + # Check if there are conflicts + if git status | grep -q "Unmerged paths\|both modified"; then + echo "Conflicts detected during cherry-pick" + CONFLICT=true + + # Stage all changes + git add . + + # Commit with conflict message + git commit -m "Cherry-pick $MERGE_COMMIT_SHA with conflicts" || { + echo "Error: Failed to commit conflicts" + git checkout main + git branch -D "$NEW_BRANCH" 2>/dev/null || true + continue + } + else + echo "Error: Cherry-pick failed with non-conflict error" + git cherry-pick --abort 2>/dev/null || true + git checkout main + git branch -D "$NEW_BRANCH" 2>/dev/null || true + continue + fi + else + # Cherry-pick succeeded, amend to update author + git commit --amend --no-edit --reset-author || { + echo "Error: Failed to amend commit" + git checkout main + git branch -D "$NEW_BRANCH" 2>/dev/null || true + continue + } + fi + + # Push the new branch + if [ "$DRY_RUN" = "true" ]; then + echo " [DRY RUN] Would push branch: $NEW_BRANCH" + echo " Command: git push -f origin $NEW_BRANCH" + NEW_PR_NUMBER="" + else + git push -f origin "$NEW_BRANCH" || { + echo "Error: Failed to push branch $NEW_BRANCH" + git checkout main + git branch -D "$NEW_BRANCH" 2>/dev/null || true + continue + } + + # Create PR body + PR_BODY="## Description"$'\n'"This is a $PORT_TYPE of #${PR_NUMBER}" + + # Determine if PR should be draft + DRAFT_FLAG="" + if [ "$CONFLICT" = true ]; then + DRAFT_FLAG="--draft" + fi + + # Build labels JSON array from OTHER_LABELS and add port type specific labels + if [ "$CONFLICT" = true ]; then + LABELS_JSON=$(echo "$OTHER_LABELS" | jq -c '. + ["Merge Conflict", "Skip CI", "Forwardport"]') + else + LABELS_JSON=$(echo "$OTHER_LABELS" | jq -c '. + ["Forwardport"]') + fi + + # Convert JSON array to comma-separated list for gh pr create --label + LABELS_LIST=$(echo "$LABELS_JSON" | jq -r 'join(",")') + + echo " Creating pull request..." + # Create the pull request with labels (returns URL like https://github.com/owner/repo/pull/123) + PR_URL=$(gh pr create \ + --title "[$BRANCH] $PR_TITLE (#$PR_NUMBER)" \ + --body "$PR_BODY" \ + --base "$BRANCH" \ + --head "$NEW_BRANCH" \ + --label "$LABELS_LIST" \ + $DRAFT_FLAG 2>&1) + + if [ $? -ne 0 ] || [ -z "$PR_URL" ]; then + echo "Error: Failed to create PR for branch $NEW_BRANCH" + echo "$PR_URL" + git checkout main + continue + fi + + # Extract PR number from URL using gh pr view + NEW_PR_NUMBER=$(gh pr view "$PR_URL" --json number --jq '.number') + + echo "Created forwardport PR #$NEW_PR_NUMBER ($PR_URL)" + fi + + # Build labels JSON array for dry-run display + if [ "$CONFLICT" = true ]; then + LABELS_JSON=$(echo "$OTHER_LABELS" | jq -c '. + ["Merge Conflict", "Skip CI", "Forwardport"]') + else + LABELS_JSON=$(echo "$OTHER_LABELS" | jq -c '. + ["Forwardport"]') + fi + + # Display PR information (for both dry-run and real mode) + if [ "$DRY_RUN" = "true" ]; then + echo "" + echo " [DRY RUN] Would create PR with:" + echo " --------------------------------" + echo " Title: [$BRANCH] $PR_TITLE (#$PR_NUMBER)" + echo " Body: ## Description" + echo " This is a $PORT_TYPE of #${PR_NUMBER}" + echo " Base: $BRANCH" + echo " Head: $NEW_BRANCH" + if [ "$CONFLICT" = true ]; then + echo " Draft: true (due to conflicts)" + else + echo " Draft: false" + fi + echo " Repository: ${{ github.repository }}" + echo "" + echo " [DRY RUN] Would add labels:" + echo "$LABELS_JSON" | jq -r '.[]' | while read -r label; do + echo " - $label" + done + fi + + # Add conflict comment if there were conflicts + if [ "$CONFLICT" = true ]; then + CONFLICT_COMMENT="Hello @${PR_AUTHOR}, there are conflicts in this ${PORT_TYPE}."$'\n\n' + CONFLICT_COMMENT+="Please address them in order to merge this Pull Request. You can execute the snippet below to reset your branch and resolve the conflict manually."$'\n\n' + CONFLICT_COMMENT+="Make sure you replace \`origin\` by the name of the ${{ github.repository_owner }}/${{ github.event.repository.name }} remote"$'\n' + CONFLICT_COMMENT+="\`\`\`"$'\n' + CONFLICT_COMMENT+="git fetch --all"$'\n' + CONFLICT_COMMENT+="gh pr checkout ${NEW_PR_NUMBER} -R ${{ github.repository }}"$'\n' + CONFLICT_COMMENT+="git reset --hard origin/${BRANCH}"$'\n' + CONFLICT_COMMENT+="git cherry-pick -m 1 ${MERGE_COMMIT_SHA}"$'\n' + CONFLICT_COMMENT+="\`\`\`" + + if [ "$DRY_RUN" = "true" ]; then + echo "" + echo " [DRY RUN] Would add conflict resolution comment:" + echo " ------------------------------------------------" + echo "$CONFLICT_COMMENT" | sed 's/^/ /' + else + if ! OUTPUT=$(gh pr comment "$NEW_PR_NUMBER" --body "$CONFLICT_COMMENT" 2>&1); then + echo "Warning: Could not add conflict resolution comment" + echo " Error: $OUTPUT" + fi + fi + fi + + # Get reviewers from original PR and build JSON array + REVIEWERS_JSON=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/requested_reviewers" \ + --jq '[.users[].login, .teams[].slug] + ["'"$PR_AUTHOR"'"]' 2>/dev/null || echo '["'"$PR_AUTHOR"'"]') + + if [ "$DRY_RUN" = "true" ]; then + echo "" + echo " [DRY RUN] Would request reviews from:" + echo "$REVIEWERS_JSON" | jq -r '.[]' | while read -r reviewer; do + echo " - $reviewer" + done + else + # Convert JSON array to CSV and request reviewers + REVIEWERS_CSV=$(echo "$REVIEWERS_JSON" | jq -r '@csv') + if ! OUTPUT=$(gh pr edit "$NEW_PR_NUMBER" --add-reviewer "$REVIEWERS_CSV" 2>&1); then + echo "Note: Could not add some reviewers (may include PR author or have insufficient permissions)" + echo " Error: $OUTPUT" + fi + fi + + # Return to main branch for next iteration + git checkout main + done <<< "$FORWARDPORT_BRANCHES"