11# Workflow: Group Dependabot PRs
22# Description:
33# 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.
4+ # It merges individual PR changes into grouped branches in chronological order (sorted by PR number) , resolves merge conflicts automatically, and opens consolidated PRs.
55# It also closes the original Dependabot PRs and carries over their labels and metadata.
66# Improvements:
7- # - Handles multiple conflicting files during cherry-pick
7+ # - Uses merge strategy instead of cherry-pick for better conflict resolution
8+ # - Sorts PRs by number at fetch time for consistent chronological processing
9+ # - Handles multiple conflicting files during merge
810# - Deduplicates entries in PR description
911# - Avoids closing original PRs unless grouped PR creation succeeds
1012# - More efficient retry logic
1113# - Ecosystem grouping is now configurable via native YAML map
12- # - Uses safe namespaced branch naming (e.g. actions /grouped-...) to avoid developer conflict
14+ # - Uses safe namespaced branch naming (e.g. security /grouped-...) to avoid developer conflict
1315# - Ensures PR body formatting uses real newlines for better readability
1416# - Adds strict error handling for script robustness
1517# - Accounts for tool dependencies (jq, gh) and race conditions
@@ -74,14 +76,14 @@ jobs:
7476 run : |
7577 set -euo pipefail
7678
77- - name : Fetch open Dependabot PRs targeting main
79+ - name : Fetch open Dependabot PRs targeting main (sorted by PR number)
7880 id : fetch_prs
7981 run : |
8082 gh pr list \
8183 --search "author:dependabot[bot] base:$TARGET_BRANCH is:open" \
8284 --limit 100 \
8385 --json number,title,headRefName,labels,files,url \
84- --jq '[.[] | {number, title, url, ref: .headRefName, labels: [.labels[].name], files: [.files[].path]}]' > prs.json
86+ --jq '[.[] | {number, title, url, ref: .headRefName, labels: [.labels[].name], files: [.files[].path]}] | sort_by(.number) ' > prs.json
8587 cat prs.json
8688
8789 - name : Validate prs.json
@@ -107,14 +109,16 @@ jobs:
107109 run : |
108110 echo "Running in dry-run mode. No changes will be pushed or PRs created/closed."
109111
110- - name : Group PRs by ecosystem and cherry-pick with retry
112+ - name : Group PRs by ecosystem and merge in sorted order
111113 run : |
112114 declare -A GROUP_CONFIG=(
113115 [pip]="${GROUP_CONFIG_PIP:-backend}"
114116 [npm]="${GROUP_CONFIG_NPM:-frontend}"
115117 [yarn]="${GROUP_CONFIG_YARN:-frontend}"
116118 )
117119 mkdir -p grouped
120+
121+ # Group PRs by ecosystem (data is already sorted by PR number)
118122 jq -c '.[]' prs.json | while read pr; do
119123 ref=$(echo "$pr" | jq -r '.ref')
120124 number=$(echo "$pr" | jq -r '.number')
@@ -136,93 +140,132 @@ jobs:
136140 exit 0
137141 fi
138142
143+ # Pre-load PR metadata for efficient lookup
139144 declare -A pr_metadata_map
140145 while IFS=$'\t' read -r number title url labels; do
141146 pr_metadata_map["$number"]="$title|$url|$labels"
142147 done < <(jq -r '.[] | "\(.number)\t\(.title)\t\(.url)\t\(.labels | join(","))"' prs.json)
143148
149+ # Process each group
144150 for file in "${grouped_files[@]}"; do
145151 group_name=$(basename "$file" .txt)
146152 safe_group_name=$(echo "$group_name" | tr -c '[:alnum:]_-' '-')
147153 branch_name="security/grouped-${safe_group_name}-updates"
154+
155+ echo "Processing group: $group_name (branch: $branch_name)"
156+
157+ # Create or checkout the grouped branch
148158 git checkout -B "$branch_name"
149159
150- git fetch origin "$branch_name" || true
151- git merge origin/"$branch_name" --no-edit || true
160+ # Try to merge existing grouped branch if it exists on remote
161+ git fetch origin "$branch_name" 2>/dev/null || true
162+ if git rev-parse "origin/$branch_name" >/dev/null 2>&1; then
163+ echo "Merging existing remote branch origin/$branch_name"
164+ git merge origin/"$branch_name" --no-edit || true
165+ fi
152166
167+ # Merge PRs in sorted order (file is already sorted by PR number from jq)
168+ merge_success=true
153169 while read -r number ref group; do
170+ echo "Merging PR #$number ($ref) into $branch_name"
154171 git fetch origin "$ref"
155- if ! git cherry-pick FETCH_HEAD; then
156- echo "Conflict found in $ref. Attempting to resolve."
172+
173+ if ! git merge FETCH_HEAD --no-edit -m "Merge PR #$number: $(echo "${pr_metadata_map["$number"]}" | cut -d'|' -f1)"; then
174+ echo "Conflict found when merging PR #$number ($ref). Attempting to resolve."
157175 conflict_files=($(git diff --name-only --diff-filter=U))
176+
158177 if [ ${#conflict_files[@]} -gt 0 ]; then
159178 echo "Resolving conflicts in files: ${conflict_files[*]}"
160179 for conflict_file in "${conflict_files[@]}"; do
161- echo "Resolving conflict in $conflict_file"
180+ echo "Resolving conflict in $conflict_file using theirs strategy "
162181 git checkout --theirs "$conflict_file"
163182 git add "$conflict_file"
164183 done
165- git cherry-pick --continue || {
166- echo "Failed to continue cherry-pick. Aborting."
167- git cherry-pick --abort
168- continue 2
169- }
184+
185+ if git commit --no-edit -m "Merge PR #$number: $(echo "${pr_metadata_map["$number"]}" | cut -d'|' -f1) (with conflict resolution)"; then
186+ echo "Successfully resolved conflicts and committed merge for PR #$number"
187+ else
188+ echo "Failed to commit merge resolution for PR #$number. Aborting merge."
189+ git merge --abort
190+ merge_success=false
191+ continue
192+ fi
170193 else
171- echo "No conflicting files found. Aborting."
172- git cherry-pick --abort
173- continue 2
194+ echo "No conflicting files found. Aborting merge for PR #$number."
195+ git merge --abort
196+ merge_success=false
197+ continue
174198 fi
199+ else
200+ echo "Successfully merged PR #$number without conflicts"
175201 fi
176202 done < "$file"
177203
204+ if [ "$merge_success" = false ]; then
205+ echo "Some merges failed for group $group_name. Skipping push and PR creation."
206+ continue
207+ fi
208+
209+ # Push the grouped branch
178210 if [ "$DRY_RUN" == "true" ]; then
179211 echo "[DRY-RUN] Skipping git push for $branch_name"
180212 else
181213 git push --force origin "$branch_name"
182214 fi
183215
216+ # Build PR description with all included PRs (in sorted order)
184217 new_lines=""
185218 while read -r number ref group; do
186219 IFS="|" read -r title url _ <<< "${pr_metadata_map["$number"]}"
187- new_lines+="$title - [#$number]($url)\n"
220+ new_lines+="- $title - [#$number]($url)\n"
188221 done < "$file"
189222
190- pr_title="chore(deps): bump grouped $group_name Dependabot updates "
223+ pr_title="chore(deps): bump grouped $group_name dependencies "
191224 existing_url=$(gh pr list --head "$branch_name" --base "$TARGET_BRANCH" --state open --json url --jq '.[0].url // empty')
192225
226+ # Create or update the grouped PR
193227 if [ -n "$existing_url" ]; then
194228 echo "PR already exists: $existing_url"
195229 pr_url="$existing_url"
196230 current_body=$(gh pr view "$pr_url" --json body --jq .body)
231+
232+ # Deduplicate entries in PR description
197233 IFS=$'\n' read -d '' -r -a current_lines < <(printf '%s\0' "$current_body")
198234 IFS=$'\n' read -d '' -r -a new_lines_arr < <(printf '%b\0' "$new_lines")
199235 declare -A seen
200236 for line in "${current_lines[@]}"; do
201237 seen["$line"]=1
202238 done
239+
203240 filtered_lines=""
204241 for line in "${new_lines_arr[@]}"; do
205242 if [[ -n "$line" && -z "${seen["$line"]}" ]]; then
206243 filtered_lines+="$line\n"
207244 fi
208245 done
246+
209247 if [ -n "$filtered_lines" ]; then
210248 new_body="$current_body"$'\n'$filtered_lines
211249 else
212250 new_body="$current_body"
213251 fi
252+
214253 if [ "$DRY_RUN" == "true" ]; then
215254 echo "[DRY-RUN] Would update PR body for $pr_url"
255+ echo "New content would be:"
256+ echo "$new_body"
216257 else
217258 tmpfile=$(mktemp)
218259 printf '%s' "$new_body" > "$tmpfile"
219260 gh pr edit "$pr_url" --body-file "$tmpfile"
220261 rm -f "$tmpfile"
262+ echo "Updated existing PR: $pr_url"
221263 fi
222264 else
223- pr_body=$(printf "This PR groups multiple open PRs by Dependabot for %s .\n\n%b" "$group_name" "$new_lines")
265+ pr_body=$(printf "This PR groups multiple open Dependabot PRs for the %s ecosystem .\n\n**Included PRs:** \n%b" "$group_name" "$new_lines")
224266 if [ "$DRY_RUN" == "true" ]; then
225267 echo "[DRY-RUN] Would create PR titled: $pr_title"
268+ echo "PR body would be:"
226269 echo "$pr_body"
227270 pr_url=""
228271 else
@@ -231,28 +274,41 @@ jobs:
231274 --body "$pr_body" \
232275 --base "$TARGET_BRANCH" \
233276 --head "$branch_name")
277+ echo "Created new grouped PR: $pr_url"
234278 fi
235279 fi
236280
281+ # Add labels and close original PRs only if grouped PR was created/updated successfully
237282 if [ -n "$pr_url" ]; then
238- for number in $(cut -d ' ' -f1 "$file") ; do
283+ while read -r number ref group ; do
239284 IFS="|" read -r _ _ labels <<< "${pr_metadata_map["$number"]}"
240- IFS="," read -ra label_arr <<< "$labels"
241- for label in "${label_arr[@]}"; do
242- if [ "$DRY_RUN" == "true" ]; then
243- echo "[DRY-RUN] Would add label $label to $pr_url"
244- else
245- gh pr edit "$pr_url" --add-label "$label"
246- fi
247- done
285+
286+ # Add labels from original PRs to grouped PR
287+ if [ -n "$labels" ]; then
288+ IFS="," read -ra label_arr <<< "$labels"
289+ for label in "${label_arr[@]}"; do
290+ if [ -n "$label" ]; then
291+ if [ "$DRY_RUN" == "true" ]; then
292+ echo "[DRY-RUN] Would add label '$label' to $pr_url"
293+ else
294+ gh pr edit "$pr_url" --add-label "$label" 2>/dev/null || echo "Failed to add label '$label'"
295+ fi
296+ fi
297+ done
298+ fi
299+
300+ # Close original PR
248301 if [ "$DRY_RUN" == "true" ]; then
249302 echo "[DRY-RUN] Would close PR #$number"
250303 else
251- gh pr close "$number" --comment "Grouped into $pr_url. "
304+ gh pr close "$number" --comment "Grouped into $pr_url for easier review and testing." || echo "Failed to close PR #$number "
252305 fi
253- done
254- echo "Grouped PR created. Leaving branch $branch_name for now."
306+ done < "$file"
307+
308+ echo "✅ Successfully processed group '$group_name' with branch '$branch_name'"
255309 else
256- echo "Grouped PR was not created. Skipping closing of original PRs."
310+ echo "❌ Grouped PR was not created for group '$group_name' . Skipping closing of original PRs."
257311 fi
258312 done
313+
314+ echo "🎉 Workflow completed successfully!"
0 commit comments