|  | 
|  | 1 | +# Workflow: Group Dependabot PRs | 
|  | 2 | +# Description: | 
|  | 3 | +# This GitHub Actions workflow automatically groups open Dependabot PRs by ecosystem (pip, npm). | 
|  | 4 | +# It cherry-picks individual PR changes into grouped branches, resolves merge conflicts automatically, and opens consolidated PRs. | 
|  | 5 | +# It also closes the original Dependabot PRs and carries over their labels and metadata. | 
|  | 6 | +# Improvements: | 
|  | 7 | +# - Handles multiple conflicting files during cherry-pick | 
|  | 8 | +# - Deduplicates entries in PR description | 
|  | 9 | +# - Avoids closing original PRs unless grouped PR creation succeeds | 
|  | 10 | +# - More efficient retry logic | 
|  | 11 | +# - Ecosystem grouping is now configurable via native YAML map | 
|  | 12 | +# - Uses safe namespaced branch naming (e.g. actions/grouped-...) to avoid developer conflict | 
|  | 13 | +# - Ensures PR body formatting uses real newlines for better readability | 
|  | 14 | +# - Adds strict error handling for script robustness | 
|  | 15 | +# - Accounts for tool dependencies (jq, gh) and race conditions | 
|  | 16 | +# - Optimized PR metadata lookup by preloading into associative array | 
|  | 17 | +# - Supports --dry-run mode for validation/testing without side effects | 
|  | 18 | +# - Note: PRs created during workflow execution will be picked up in the next scheduled run. | 
|  | 19 | + | 
|  | 20 | +name: Group Dependabot PRs | 
|  | 21 | + | 
|  | 22 | +on: | 
|  | 23 | +  schedule: | 
|  | 24 | +    - cron: '0 0 * * *'  # Run daily at midnight UTC | 
|  | 25 | +  workflow_dispatch: | 
|  | 26 | +    inputs: | 
|  | 27 | +      group_config_pip: | 
|  | 28 | +        description: "Group name for pip ecosystem" | 
|  | 29 | +        required: false | 
|  | 30 | +        default: "backend" | 
|  | 31 | +      group_config_npm: | 
|  | 32 | +        description: "Group name for npm ecosystem" | 
|  | 33 | +        required: false | 
|  | 34 | +        default: "frontend" | 
|  | 35 | +      group_config_yarn: | 
|  | 36 | +        description: "Group name for yarn ecosystem" | 
|  | 37 | +        required: false | 
|  | 38 | +        default: "frontend" | 
|  | 39 | +      dry_run: | 
|  | 40 | +        description: "Run in dry-run mode (no changes will be pushed or PRs created/closed)" | 
|  | 41 | +        required: false | 
|  | 42 | +        default: false | 
|  | 43 | +        type: boolean | 
|  | 44 | + | 
|  | 45 | +jobs: | 
|  | 46 | +  group-dependabot-prs: | 
|  | 47 | +    runs-on: ubuntu-latest | 
|  | 48 | +    permissions: | 
|  | 49 | +      contents: write | 
|  | 50 | +      pull-requests: write | 
|  | 51 | +    env: | 
|  | 52 | +      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | 
|  | 53 | +      TARGET_BRANCH: "main" | 
|  | 54 | +      DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} | 
|  | 55 | +      GROUP_CONFIG_PIP: ${{ github.event.inputs.group_config_pip || 'backend' }} | 
|  | 56 | +      GROUP_CONFIG_NPM: ${{ github.event.inputs.group_config_npm || 'frontend' }} | 
|  | 57 | +      GROUP_CONFIG_YARN: ${{ github.event.inputs.group_config_yarn || 'frontend' }} | 
|  | 58 | +    steps: | 
|  | 59 | +      - name: Checkout default branch | 
|  | 60 | +        uses: actions/checkout@v4 | 
|  | 61 | + | 
|  | 62 | +      - name: Set up Git | 
|  | 63 | +        run: | | 
|  | 64 | +          git config --global user.name "github-actions" | 
|  | 65 | +          git config --global user.email "[email protected]" | 
|  | 66 | +
 | 
|  | 67 | +      - name: Install required tools | 
|  | 68 | + | 
|  | 69 | +        with: | 
|  | 70 | +          packages: "jq gh" | 
|  | 71 | + | 
|  | 72 | +      - name: Enable strict error handling | 
|  | 73 | +        shell: bash | 
|  | 74 | +        run: | | 
|  | 75 | +          set -euo pipefail | 
|  | 76 | +
 | 
|  | 77 | +      - name: Fetch open Dependabot PRs targeting main | 
|  | 78 | +        id: fetch_prs | 
|  | 79 | +        run: | | 
|  | 80 | +          gh pr list \ | 
|  | 81 | +            --search "author:dependabot[bot] base:$TARGET_BRANCH is:open" \ | 
|  | 82 | +            --limit 100 \ | 
|  | 83 | +            --json number,title,headRefName,labels,files,url \ | 
|  | 84 | +            --jq '[.[] | {number, title, url, ref: .headRefName, labels: [.labels[].name], files: [.files[].path]}]' > prs.json | 
|  | 85 | +          cat prs.json | 
|  | 86 | +
 | 
|  | 87 | +      - name: Validate prs.json | 
|  | 88 | +        run: | | 
|  | 89 | +          jq empty prs.json 2> jq_error.log || { echo "Malformed JSON in prs.json: $(cat jq_error.log)"; exit 1; } | 
|  | 90 | +
 | 
|  | 91 | +      - name: Check if any PRs exist | 
|  | 92 | +        id: check_prs | 
|  | 93 | +        run: | | 
|  | 94 | +          count=$(jq length prs.json) | 
|  | 95 | +          echo "Found $count PRs" | 
|  | 96 | +          if [ "$count" -eq 0 ]; then | 
|  | 97 | +            echo "No PRs to group. Exiting." | 
|  | 98 | +            echo "skip=true" >> $GITHUB_OUTPUT | 
|  | 99 | +          fi | 
|  | 100 | +
 | 
|  | 101 | +      - name: Exit early if no PRs | 
|  | 102 | +        if: steps.check_prs.outputs.skip == 'true' | 
|  | 103 | +        run: exit 0 | 
|  | 104 | + | 
|  | 105 | +      - name: Dry-run validation (CI/test only) | 
|  | 106 | +        if: env.DRY_RUN == 'true' | 
|  | 107 | +        run: | | 
|  | 108 | +          echo "Running in dry-run mode. No changes will be pushed or PRs created/closed." | 
|  | 109 | +          # Optionally, add more validation logic here (e.g., check grouped files, print planned actions). | 
|  | 110 | +
 | 
|  | 111 | +      - name: Group PRs by ecosystem and cherry-pick with retry | 
|  | 112 | +        run: | | 
|  | 113 | +          declare -A GROUP_CONFIG=( | 
|  | 114 | +            [pip]="${GROUP_CONFIG_PIP:-backend}" | 
|  | 115 | +            [npm]="${GROUP_CONFIG_NPM:-frontend}" | 
|  | 116 | +            [yarn]="${GROUP_CONFIG_YARN:-frontend}" | 
|  | 117 | +          ) | 
|  | 118 | +          mkdir -p grouped | 
|  | 119 | +          jq -c '.[]' prs.json | while read pr; do | 
|  | 120 | +            ref=$(echo "$pr" | jq -r '.ref') | 
|  | 121 | +            number=$(echo "$pr" | jq -r '.number') | 
|  | 122 | +            group="misc" | 
|  | 123 | +            for key in "${!GROUP_CONFIG[@]}"; do | 
|  | 124 | +              if [[ "$ref" == *"$key"* ]]; then | 
|  | 125 | +                group="${GROUP_CONFIG[$key]}" | 
|  | 126 | +                break | 
|  | 127 | +              fi | 
|  | 128 | +            done | 
|  | 129 | +            echo "$number $ref $group" >> grouped/$group.txt | 
|  | 130 | +          done | 
|  | 131 | +
 | 
|  | 132 | +          shopt -s nullglob | 
|  | 133 | +          grouped_files=(grouped/*.txt) | 
|  | 134 | +
 | 
|  | 135 | +          if [ ${#grouped_files[@]} -eq 0 ]; then | 
|  | 136 | +            echo "No groups were formed. Exiting." | 
|  | 137 | +            exit 0 | 
|  | 138 | +          fi | 
|  | 139 | +
 | 
|  | 140 | +          declare -A pr_metadata_map | 
|  | 141 | +          while IFS=$'\t' read -r number title url labels; do | 
|  | 142 | +            pr_metadata_map["$number"]="$title|$url|$labels" | 
|  | 143 | +          done < <(jq -r '.[] | "\(.number)\t\(.title)\t\(.url)\t\(.labels | join(","))"' prs.json) | 
|  | 144 | +
 | 
|  | 145 | +          for file in "${grouped_files[@]}"; do | 
|  | 146 | +            group_name=$(basename "$file" .txt) | 
|  | 147 | +            # Sanitize group_name: allow only alphanum, dash, underscore | 
|  | 148 | +            safe_group_name=$(echo "$group_name" | tr -c '[:alnum:]_-' '-') | 
|  | 149 | +            branch_name="security/grouped-${safe_group_name}-updates" | 
|  | 150 | +            git checkout -B "$branch_name" | 
|  | 151 | +
 | 
|  | 152 | +            while read -r number ref group; do | 
|  | 153 | +              git fetch origin "$ref" | 
|  | 154 | +              if ! git cherry-pick FETCH_HEAD; then | 
|  | 155 | +                echo "Conflict found in $ref. Attempting to resolve." | 
|  | 156 | +                conflict_files=($(git diff --name-only --diff-filter=U)) | 
|  | 157 | +                if [ ${#conflict_files[@]} -gt 0 ]; then | 
|  | 158 | +                  echo "Resolving conflicts in files: ${conflict_files[*]}" | 
|  | 159 | +                  for conflict_file in "${conflict_files[@]}"; do | 
|  | 160 | +                    echo "Resolving conflict in $conflict_file" | 
|  | 161 | +                    git checkout --theirs "$conflict_file" | 
|  | 162 | +                    git add "$conflict_file" | 
|  | 163 | +                  done | 
|  | 164 | +                  git cherry-pick --continue || { | 
|  | 165 | +                    echo "Failed to continue cherry-pick. Aborting." | 
|  | 166 | +                    git cherry-pick --abort | 
|  | 167 | +                    continue 2 | 
|  | 168 | +                  } | 
|  | 169 | +                else | 
|  | 170 | +                  echo "No conflicting files found. Aborting." | 
|  | 171 | +                  git cherry-pick --abort | 
|  | 172 | +                  continue 2 | 
|  | 173 | +                fi | 
|  | 174 | +              fi | 
|  | 175 | +            done < "$file" | 
|  | 176 | +
 | 
|  | 177 | +            # Non-destructive push: check for drift before force-pushing | 
|  | 178 | +            if [ "$DRY_RUN" == "true" ]; then | 
|  | 179 | +              echo "[DRY-RUN] Skipping git push for $branch_name" | 
|  | 180 | +            else | 
|  | 181 | +              remote_hash=$(git ls-remote origin "$branch_name" | awk '{print $1}') | 
|  | 182 | +              local_hash=$(git rev-parse "$branch_name") | 
|  | 183 | +              if [ -n "$remote_hash" ] && [ "$remote_hash" != "$local_hash" ]; then | 
|  | 184 | +                echo "Remote branch $branch_name has diverged. Skipping force-push to avoid overwriting changes." | 
|  | 185 | +                continue | 
|  | 186 | +              fi | 
|  | 187 | +              git push --force-with-lease origin "$branch_name" | 
|  | 188 | +            fi | 
|  | 189 | +
 | 
|  | 190 | +            new_lines="" | 
|  | 191 | +            while read -r number ref group; do | 
|  | 192 | +              IFS="|" read -r title url _ <<< "${pr_metadata_map["$number"]}" | 
|  | 193 | +              new_lines+="$title - [#$number]($url)\n" | 
|  | 194 | +            done < "$file" | 
|  | 195 | +
 | 
|  | 196 | +            pr_title="chore(deps): bump grouped $group_name Dependabot updates" | 
|  | 197 | +            # Add --state open to ensure only open PRs are considered | 
|  | 198 | +            existing_url=$(gh pr list --head "$branch_name" --base "$TARGET_BRANCH" --state open --json url --jq '.[0].url // empty') | 
|  | 199 | +
 | 
|  | 200 | +            if [ -n "$existing_url" ]; then | 
|  | 201 | +              echo "PR already exists: $existing_url" | 
|  | 202 | +              pr_url="$existing_url" | 
|  | 203 | +              current_body=$(gh pr view "$pr_url" --json body --jq .body) | 
|  | 204 | +              # Simplified duplicate-detection using Bash array | 
|  | 205 | +              IFS=$'\n' read -d '' -r -a current_lines < <(printf '%s\0' "$current_body") | 
|  | 206 | +              IFS=$'\n' read -d '' -r -a new_lines_arr < <(printf '%b\0' "$new_lines") | 
|  | 207 | +              declare -A seen | 
|  | 208 | +              for line in "${current_lines[@]}"; do | 
|  | 209 | +                seen["$line"]=1 | 
|  | 210 | +              done | 
|  | 211 | +              filtered_lines="" | 
|  | 212 | +              for line in "${new_lines_arr[@]}"; do | 
|  | 213 | +                if [[ -n "$line" && -z "${seen["$line"]}" ]]; then | 
|  | 214 | +                  filtered_lines+="$line\n" | 
|  | 215 | +                fi | 
|  | 216 | +              done | 
|  | 217 | +              # Ensure a newline separator between the existing body and new lines | 
|  | 218 | +              if [ -n "$filtered_lines" ]; then | 
|  | 219 | +                new_body="$current_body"$'\n'"$filtered_lines" | 
|  | 220 | +              else | 
|  | 221 | +                new_body="$current_body" | 
|  | 222 | +              fi | 
|  | 223 | +              if [ "$DRY_RUN" == "true" ]; then | 
|  | 224 | +                echo "[DRY-RUN] Would update PR body for $pr_url" | 
|  | 225 | +              else | 
|  | 226 | +                tmpfile=$(mktemp) | 
|  | 227 | +                printf '%s' "$new_body" > "$tmpfile" | 
|  | 228 | +                gh pr edit "$pr_url" --body-file "$tmpfile" | 
|  | 229 | +                rm -f "$tmpfile" | 
|  | 230 | +              fi | 
|  | 231 | +            else | 
|  | 232 | +              pr_body=$(printf "This PR groups multiple open PRs by Dependabot for %s.\n\n%b" "$group_name" "$new_lines") | 
|  | 233 | +              if [ "$DRY_RUN" == "true" ]; then | 
|  | 234 | +                echo "[DRY-RUN] Would create PR titled: $pr_title" | 
|  | 235 | +                echo "$pr_body" | 
|  | 236 | +                pr_url="" | 
|  | 237 | +              else | 
|  | 238 | +                pr_url=$(gh pr create \ | 
|  | 239 | +                  --title "$pr_title" \ | 
|  | 240 | +                  --body "$pr_body" \ | 
|  | 241 | +                  --base "$TARGET_BRANCH" \ | 
|  | 242 | +                  --head "$branch_name") | 
|  | 243 | +              fi | 
|  | 244 | +            fi | 
|  | 245 | +
 | 
|  | 246 | +            if [ -n "$pr_url" ]; then | 
|  | 247 | +              for number in $(cut -d ' ' -f1 "$file"); do | 
|  | 248 | +                IFS="|" read -r _ _ labels <<< "${pr_metadata_map["$number"]}" | 
|  | 249 | +                IFS="," read -ra label_arr <<< "$labels" | 
|  | 250 | +                for label in "${label_arr[@]}"; do | 
|  | 251 | +                  if [ "$DRY_RUN" == "true" ]; then | 
|  | 252 | +                    echo "[DRY-RUN] Would add label $label to $pr_url" | 
|  | 253 | +                  else | 
|  | 254 | +                    gh pr edit "$pr_url" --add-label "$label" | 
|  | 255 | +                  fi | 
|  | 256 | +                done | 
|  | 257 | +                if [ "$DRY_RUN" == "true" ]; then | 
|  | 258 | +                  echo "[DRY-RUN] Would close PR #$number" | 
|  | 259 | +                else | 
|  | 260 | +                  gh pr close "$number" --comment "Grouped into $pr_url." | 
|  | 261 | +                fi | 
|  | 262 | +              done | 
|  | 263 | +              echo "Grouped PR created. Leaving branch $branch_name for now." | 
|  | 264 | +            else | 
|  | 265 | +              echo "Grouped PR was not created. Skipping closing of original PRs." | 
|  | 266 | +            fi | 
|  | 267 | +          done | 
0 commit comments