|
| 1 | +#!/bin/bash |
| 2 | +# |
| 3 | +# DBDiff Pull Request Manager |
| 4 | +# |
| 5 | +# Usage: ./scripts/manage_prs.sh [--apply] [--auto-close] [--resolve-conflicts] |
| 6 | +# |
| 7 | +# Dependencies: gh (GitHub CLI), jq |
| 8 | +# |
| 9 | +# This script is designed to be defensive and robust. It will: |
| 10 | +# 1. Iterate through open PRs. |
| 11 | +# 2. Attempt to update branches (handling API errors gracefully). |
| 12 | +# 3. Check statuses (handling confusing JSON structures safely). |
| 13 | +# 4. Merge if conditions are met. |
| 14 | +# 5. Provide a detailed summary at the end. |
| 15 | + |
| 16 | +set -o pipefail |
| 17 | + |
| 18 | +# ----------------------------------------------------------------------------- |
| 19 | +# Globals & State |
| 20 | +# ----------------------------------------------------------------------------- |
| 21 | +DRY_RUN=true |
| 22 | +AUTO_CLOSE=false |
| 23 | +RESOLVE_CONFLICTS=false |
| 24 | +TOTAL_PRS=0 |
| 25 | +UPDATED_PRS=0 |
| 26 | +MERGED_PRS=0 |
| 27 | +SKIPPED_PRS=0 |
| 28 | +FAILED_PRS=0 |
| 29 | +ERRORS=() |
| 30 | + |
| 31 | +# ----------------------------------------------------------------------------- |
| 32 | +# Helper Functions |
| 33 | +# ----------------------------------------------------------------------------- |
| 34 | +log() { |
| 35 | + echo -e "[\033[0;34mINFO\033[0m] $1" |
| 36 | +} |
| 37 | + |
| 38 | +warn() { |
| 39 | + echo -e "[\033[0;33mWARN\033[0m] $1" |
| 40 | +} |
| 41 | + |
| 42 | +error_log() { |
| 43 | + echo -e "[\033[0;31mERROR\033[0m] $1" |
| 44 | + ERRORS+=("$1") |
| 45 | +} |
| 46 | + |
| 47 | +print_summary() { |
| 48 | + echo "" |
| 49 | + echo "========================================================" |
| 50 | + echo " EXECUTION SUMMARY " |
| 51 | + echo "========================================================" |
| 52 | + printf "Total PRs Found: %d\n" "$TOTAL_PRS" |
| 53 | + printf "Branches Updated: %d\n" "$UPDATED_PRS" |
| 54 | + printf "PRs Merged: %d\n" "$MERGED_PRS" |
| 55 | + printf "Skipped (No Action): %d\n" "$SKIPPED_PRS" |
| 56 | + printf "Failed/Errors: %d\n" "$FAILED_PRS" |
| 57 | + echo "========================================================" |
| 58 | + |
| 59 | + if [ ${#ERRORS[@]} -gt 0 ]; then |
| 60 | + echo "Errors & Warnings:" |
| 61 | + for err in "${ERRORS[@]}"; do |
| 62 | + echo " - $err" |
| 63 | + done |
| 64 | + echo "========================================================" |
| 65 | + fi |
| 66 | +} |
| 67 | + |
| 68 | +trap 'print_summary' EXIT |
| 69 | + |
| 70 | +# ----------------------------------------------------------------------------- |
| 71 | +# Dependency Checks |
| 72 | +# ----------------------------------------------------------------------------- |
| 73 | +if ! command -v gh >/dev/null 2>&1; then |
| 74 | + echo "Error: 'gh' is not installed. Please install it." |
| 75 | + exit 1 |
| 76 | +fi |
| 77 | + |
| 78 | +if ! command -v jq >/dev/null 2>&1; then |
| 79 | + echo "Error: 'jq' is not installed. Please install it." |
| 80 | + exit 1 |
| 81 | +fi |
| 82 | + |
| 83 | +if ! gh auth status >/dev/null 2>&1; then |
| 84 | + echo "Error: You must be logged in to GitHub CLI. Run 'gh auth login' first." |
| 85 | + exit 1 |
| 86 | +fi |
| 87 | + |
| 88 | +# ----------------------------------------------------------------------------- |
| 89 | +# Argument Parsing |
| 90 | +# ----------------------------------------------------------------------------- |
| 91 | +while [[ "$#" -gt 0 ]]; do |
| 92 | + case $1 in |
| 93 | + --apply) DRY_RUN=false ;; |
| 94 | + --auto-close) AUTO_CLOSE=true ;; |
| 95 | + --resolve-conflicts) RESOLVE_CONFLICTS=true ;; |
| 96 | + *) echo "Unknown parameter: $1"; exit 1 ;; |
| 97 | + esac |
| 98 | + shift |
| 99 | +done |
| 100 | + |
| 101 | +if [ "$DRY_RUN" = true ]; then |
| 102 | + log "RUNNING IN DRY-RUN MODE (Default). Use --apply to execute changes." |
| 103 | +else |
| 104 | + warn "RUNNING IN APPLY MODE. Changes WILL be made!" |
| 105 | +fi |
| 106 | + |
| 107 | +# ----------------------------------------------------------------------------- |
| 108 | +# Main Execution |
| 109 | +# ----------------------------------------------------------------------------- |
| 110 | + |
| 111 | +REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) |
| 112 | +log "Target Repository: $REPO" |
| 113 | + |
| 114 | +log "Fetching open PRs..." |
| 115 | +# Fetch PRs safely |
| 116 | +if ! PRS_JSON=$(gh pr list --state open --limit 100 --json number 2>/dev/null); then |
| 117 | + error_log "Failed to fetch PR list check your network or token." |
| 118 | + exit 1 |
| 119 | +fi |
| 120 | + |
| 121 | +TOTAL_PRS=$(echo "$PRS_JSON" | jq '. | length') |
| 122 | + |
| 123 | +if [ "$TOTAL_PRS" -eq 0 ]; then |
| 124 | + log "No open pull requests found." |
| 125 | + exit 0 |
| 126 | +fi |
| 127 | + |
| 128 | +log "Found $TOTAL_PRS open PRs. Starting processing..." |
| 129 | + |
| 130 | +for row in $(echo "${PRS_JSON}" | jq -r '.[] | @base64'); do |
| 131 | + _jq() { |
| 132 | + echo ${row} | base64 --decode | jq -r ${1} |
| 133 | + } |
| 134 | + |
| 135 | + number=$(_jq '.number') |
| 136 | + action_taken=false |
| 137 | + |
| 138 | + echo "" |
| 139 | + echo "--------------------------------------------------------" |
| 140 | + log "Processing PR #$number" |
| 141 | + |
| 142 | + # Fetch Details Safely |
| 143 | + if ! details=$(gh pr view $number --json title,body,statusCheckRollup,mergeable,headRefName,baseRefName,url 2>/dev/null); then |
| 144 | + error_log "PR #$number: Failed to fetch details. Skipping." |
| 145 | + FAILED_PRS=$((FAILED_PRS+1)) |
| 146 | + continue |
| 147 | + fi |
| 148 | + |
| 149 | + title=$(echo "$details" | jq -r .title) |
| 150 | + body=$(echo "$details" | jq -r .body) |
| 151 | + mergeable=$(echo "$details" | jq -r .mergeable) |
| 152 | + headRef=$(echo "$details" | jq -r .headRefName) |
| 153 | + url=$(echo "$details" | jq -r .url) |
| 154 | + |
| 155 | + # Safe Status Parsing |
| 156 | + statuses=$(echo "$details" | jq -r '[.statusCheckRollup[]? | (.conclusion // .status // .state)] | unique | join(",")') |
| 157 | + |
| 158 | + state="UNKNOWN" |
| 159 | + if [[ -z "$statuses" ]]; then |
| 160 | + state="NONE" |
| 161 | + elif [[ "$statuses" == *"FAILURE"* || "$statuses" == *"TIMED_OUT"* || "$statuses" == *"ACTION_REQUIRED"* ]]; then |
| 162 | + state="FAILURE" |
| 163 | + elif [[ "$statuses" == *"IN_PROGRESS"* || "$statuses" == *"QUEUED"* || "$statuses" == *"PENDING"* ]]; then |
| 164 | + state="PENDING" |
| 165 | + elif [[ "$statuses" == "SUCCESS" || "$statuses" == "COMPLETED" || "$statuses" == "SUCCESS,COMPLETED" ]]; then |
| 166 | + state="SUCCESS" |
| 167 | + else |
| 168 | + state="UNKNOWN ($statuses)" |
| 169 | + fi |
| 170 | + |
| 171 | + echo " Title: $title" |
| 172 | + echo " Branch: $headRef" |
| 173 | + echo " Mergeable: $mergeable" |
| 174 | + echo " CI Status: $state ($statuses)" |
| 175 | + echo " URL: $url" |
| 176 | + |
| 177 | + # Step 1: Update Branch |
| 178 | + if [[ "$mergeable" == "CONFLICTING" ]]; then |
| 179 | + if [ "$RESOLVE_CONFLICTS" = false ]; then |
| 180 | + warn "Branch has conflicts. Cannot update or merge. (Use --resolve-conflicts to attempt auto-fix)" |
| 181 | + else |
| 182 | + log "Attempting to RESOLVE CONFLICTS..." |
| 183 | + |
| 184 | + if [ "$DRY_RUN" = true ]; then |
| 185 | + log "[DRY-RUN] Would checkout PR, merge master, accept master's .github/ configs, and push." |
| 186 | + else |
| 187 | + # Save current state |
| 188 | + git stash -u >/dev/null 2>&1 || true |
| 189 | + original_branch=$(git branch --show-current) |
| 190 | + |
| 191 | + # Checkout PR (force to overwrite local if exists) |
| 192 | + if gh pr checkout $number --force >/dev/null 2>&1; then |
| 193 | + pr_branch=$(git branch --show-current) |
| 194 | + |
| 195 | + # Attempt merge master |
| 196 | + # We use origin/master to be sure |
| 197 | + git fetch origin master >/dev/null 2>&1 |
| 198 | + |
| 199 | + if git merge origin/master --no-commit --no-ff >/dev/null 2>&1; then |
| 200 | + log " Merge performed cleanly (Status check was stale?). Pushing..." |
| 201 | + git push >/dev/null 2>&1 |
| 202 | + state="PENDING (Update Triggered)" |
| 203 | + else |
| 204 | + # Conflict detected |
| 205 | + # Strategy: Accept Master for .github and composer.lock |
| 206 | + # 'Theirs' in a PR merge (we are on PR branch, merging master) -> Master |
| 207 | + |
| 208 | + log " Merge conflict detected. Applying 'Use Master' strategy for config files..." |
| 209 | + |
| 210 | + resolved_count=0 |
| 211 | + conflicted_files=$(git diff --name-only --diff-filter=U) |
| 212 | + |
| 213 | + for f in $conflicted_files; do |
| 214 | + if [[ "$f" == ".github/"* || "$f" == "tests/"* || "$f" == "composer.lock" ]]; then |
| 215 | + # Check if file exists in master (theirs) |
| 216 | + if git cat-file -e "origin/master:$f" >/dev/null 2>&1; then |
| 217 | + log " Resolving $f using MASTER version." |
| 218 | + git checkout --theirs "$f" >/dev/null 2>&1 |
| 219 | + git add "$f" |
| 220 | + resolved_count=$((resolved_count+1)) |
| 221 | + else |
| 222 | + # File apparently deleted in master? |
| 223 | + log " File $f missing in master. Removing..." |
| 224 | + git rm "$f" >/dev/null 2>&1 || rm "$f" |
| 225 | + git add "$f" |
| 226 | + resolved_count=$((resolved_count+1)) |
| 227 | + fi |
| 228 | + fi |
| 229 | + done |
| 230 | + |
| 231 | + # Check if all conflicts resolved |
| 232 | + if git diff --name-only --diff-filter=U | grep -q .; then |
| 233 | + warn " Could not auto-resolve all conflicts. Aborting." |
| 234 | + git merge --abort >/dev/null 2>&1 |
| 235 | + else |
| 236 | + log " All conflicts resolved ($resolved_count files). Committing..." |
| 237 | + git commit -m "Merge branch 'master' into $pr_branch (Auto-resolved conflicts)" >/dev/null 2>&1 |
| 238 | + |
| 239 | + if git push >/dev/null 2>&1; then |
| 240 | + log " Successfully pushed conflict resolutions." |
| 241 | + state="PENDING (Update Triggered)" |
| 242 | + UPDATED_PRS=$((UPDATED_PRS+1)) |
| 243 | + else |
| 244 | + error_log " Failed to push changes (Permission denied?)." |
| 245 | + fi |
| 246 | + fi |
| 247 | + fi |
| 248 | + |
| 249 | + # Switch back |
| 250 | + git checkout "$original_branch" >/dev/null 2>&1 |
| 251 | + else |
| 252 | + error_log " Failed to checkout PR #$number." |
| 253 | + fi |
| 254 | + |
| 255 | + # Restore stash if any |
| 256 | + git stash pop >/dev/null 2>&1 || true |
| 257 | + fi |
| 258 | + fi |
| 259 | + else |
| 260 | + # Only update if not explicitly "SUCCESS" (since update might trigger new tests) |
| 261 | + # OR if we just want to ensure it's up to date regardless. |
| 262 | + # Let's always try to update to be safe, unless it's already up to date. |
| 263 | + |
| 264 | + if [ "$DRY_RUN" = true ]; then |
| 265 | + log "[DRY-RUN] Would update branch." |
| 266 | + else |
| 267 | + update_out=$(gh api -X PUT "/repos/$REPO/pulls/$number/update-branch" 2>&1 || true) |
| 268 | + |
| 269 | + # Check if output is JSON or raw error |
| 270 | + if echo "$update_out" | grep -q "message"; then |
| 271 | + msg=$(echo "$update_out" | jq -r .message 2>/dev/null || echo "$update_out") |
| 272 | + if [[ "$msg" == "Accepted" ]]; then |
| 273 | + log "Update triggered (Accepted)." |
| 274 | + UPDATED_PRS=$((UPDATED_PRS+1)) |
| 275 | + action_taken=true |
| 276 | + elif [[ "$msg" == *"already up to date"* ]]; then |
| 277 | + echo " Already up-to-date." |
| 278 | + else |
| 279 | + warn "Update failed: $msg" |
| 280 | + fi |
| 281 | + else |
| 282 | + # Fallback if no message field (unexpected API response) |
| 283 | + warn "Update API response unclear: $update_out" |
| 284 | + fi |
| 285 | + fi |
| 286 | + fi |
| 287 | + |
| 288 | + # Step 2: Linked Issues & Merging |
| 289 | + issues=$(echo "$body" | grep -oEi "(Fixes|Closes|Resolves) #?[0-9]+" | awk '{print $2}' | tr -d '#' | tr '\n' ' ' | xargs) |
| 290 | + |
| 291 | + if [[ -n "$issues" ]]; then |
| 292 | + log "Linked to issue(s): $issues" |
| 293 | + |
| 294 | + if [[ "$mergeable" == "CONFLICTING" ]]; then |
| 295 | + # Already warned above |
| 296 | + : |
| 297 | + elif [[ "$state" == "SUCCESS" ]]; then |
| 298 | + if [ "$AUTO_CLOSE" = true ]; then |
| 299 | + if [ "$DRY_RUN" = true ]; then |
| 300 | + log "[DRY-RUN] Would MERGE PR #$number." |
| 301 | + MERGED_PRS=$((MERGED_PRS+1)) |
| 302 | + action_taken=true |
| 303 | + else |
| 304 | + log "Tests Passed & Mergeable. Attempting Merge..." |
| 305 | + if gh pr merge $number --merge --delete-branch >> /dev/null 2>&1; then |
| 306 | + log "Successfully MERGED PR #$number." |
| 307 | + MERGED_PRS=$((MERGED_PRS+1)) |
| 308 | + action_taken=true |
| 309 | + else |
| 310 | + error_log "PR #$number: Merge command failed." |
| 311 | + FAILED_PRS=$((FAILED_PRS+1)) |
| 312 | + fi |
| 313 | + fi |
| 314 | + else |
| 315 | + log "Tests Passed. Ready to merge (Use --auto-close)." |
| 316 | + fi |
| 317 | + else |
| 318 | + log "Tests not passing (State: $state). Skipping merge." |
| 319 | + fi |
| 320 | + else |
| 321 | + echo " No linked issues found." |
| 322 | + fi |
| 323 | + |
| 324 | + if [ "$action_taken" = false ]; then |
| 325 | + SKIPPED_PRS=$((SKIPPED_PRS+1)) |
| 326 | + fi |
| 327 | + |
| 328 | +done |
0 commit comments