|
| 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