| 1 | #!/bin/bash |
| 2 | # |
| 3 | # cherry-pick-from-milestone.sh |
| 4 | # Find commits from a GitHub milestone that are missing from the current branch |
| 5 | # and output them in chronological order for cherry-picking. |
| 6 | # |
| 7 | # Usage: ./cherry-pick-from-milestone.sh <milestone> [--dry-run] [--execute] |
| 8 | # |
| 9 | |
| 10 | set -euo pipefail |
| 11 | |
| 12 | # Colors for output |
| 13 | RED='\033[0;31m' |
| 14 | GREEN='\033[0;32m' |
| 15 | YELLOW='\033[1;33m' |
| 16 | BLUE='\033[0;34m' |
| 17 | NC='\033[0m' # No Color |
| 18 | |
| 19 | usage() { |
| 20 | cat <<EOF |
| 21 | Usage: $(basename "$0") <milestone> [options] |
| 22 | |
| 23 | Find commits from a GitHub milestone that need to be cherry-picked into the current branch. |
| 24 | |
| 25 | Arguments: |
| 26 | milestone The GitHub milestone name (e.g., v0.14.0) |
| 27 | |
| 28 | Options: |
| 29 | --dry-run Show the cherry-pick commands without executing (default) |
| 30 | --execute Actually execute the cherry-picks |
| 31 | --main-branch Specify the main branch name (default: main) |
| 32 | --help Show this help message |
| 33 | |
| 34 | Examples: |
| 35 | $(basename "$0") v0.14.0 |
| 36 | $(basename "$0") v0.14.0 --dry-run |
| 37 | $(basename "$0") v0.14.0 --execute |
| 38 | $(basename "$0") v0.14.0 --main-branch master |
| 39 | EOF |
| 40 | exit 1 |
| 41 | } |
| 42 | |
| 43 | log_info() { |
| 44 | echo -e "${BLUE}[INFO]${NC} $1" |
| 45 | } |
| 46 | |
| 47 | log_success() { |
| 48 | echo -e "${GREEN}[OK]${NC} $1" |
| 49 | } |
| 50 | |
| 51 | log_warn() { |
| 52 | echo -e "${YELLOW}[WARN]${NC} $1" |
| 53 | } |
| 54 | |
| 55 | log_error() { |
| 56 | echo -e "${RED}[ERROR]${NC} $1" >&2 |
| 57 | } |
| 58 | |
| 59 | # Default values |
| 60 | MILESTONE="" |
| 61 | DRY_RUN=true |
| 62 | MAIN_BRANCH="main" |
| 63 | |
| 64 | # Parse arguments |
| 65 | while [[ $# -gt 0 ]]; do |
| 66 | case $1 in |
| 67 | --dry-run) |
| 68 | DRY_RUN=true |
| 69 | shift |
| 70 | ;; |
| 71 | --execute) |
| 72 | DRY_RUN=false |
| 73 | shift |
| 74 | ;; |
| 75 | --main-branch) |
| 76 | MAIN_BRANCH="$2" |
| 77 | shift 2 |
| 78 | ;; |
| 79 | --help|-h) |
| 80 | usage |
| 81 | ;; |
| 82 | -*) |
| 83 | log_error "Unknown option: $1" |
| 84 | usage |
| 85 | ;; |
| 86 | *) |
| 87 | if [[ -z "$MILESTONE" ]]; then |
| 88 | MILESTONE="$1" |
| 89 | else |
| 90 | log_error "Unexpected argument: $1" |
| 91 | usage |
| 92 | fi |
| 93 | shift |
| 94 | ;; |
| 95 | esac |
| 96 | done |
| 97 | |
| 98 | # Validate milestone argument |
| 99 | if [[ -z "$MILESTONE" ]]; then |
| 100 | log_error "Milestone is required" |
| 101 | usage |
| 102 | fi |
| 103 | |
| 104 | # Check if we're in a git repository |
| 105 | if ! git rev-parse --is-inside-work-tree &>/dev/null; then |
| 106 | log_error "Not in a git repository" |
| 107 | exit 1 |
| 108 | fi |
| 109 | |
| 110 | # Check if gh CLI is available |
| 111 | if ! command -v gh &>/dev/null; then |
| 112 | log_error "GitHub CLI (gh) is not installed" |
| 113 | exit 1 |
| 114 | fi |
| 115 | |
| 116 | # Check if authenticated with gh |
| 117 | if ! gh auth status &>/dev/null; then |
| 118 | log_error "Not authenticated with GitHub CLI. Run 'gh auth login' first." |
| 119 | exit 1 |
| 120 | fi |
| 121 | |
| 122 | CURRENT_BRANCH=$(git branch --show-current) |
| 123 | log_info "Current branch: ${CURRENT_BRANCH}" |
| 124 | log_info "Main branch: ${MAIN_BRANCH}" |
| 125 | log_info "Milestone: ${MILESTONE}" |
| 126 | echo "" |
| 127 | |
| 128 | # Fetch latest from remote |
| 129 | log_info "Fetching latest from remote..." |
| 130 | git fetch origin "$MAIN_BRANCH" --quiet |
| 131 | |
| 132 | # Get merged PRs from the milestone, sorted by merge date |
| 133 | log_info "Fetching merged PRs from milestone '${MILESTONE}'..." |
| 134 | |
| 135 | # Store PR data in a temp file |
| 136 | PR_DATA=$(mktemp) |
| 137 | trap 'rm -f "$PR_DATA"' EXIT |
| 138 | |
| 139 | if ! gh pr list --state merged --search "milestone:${MILESTONE}" \ |
| 140 | --limit 1000 \ |
| 141 | --json number,title,mergeCommit,mergedAt \ |
| 142 | --jq 'sort_by(.mergedAt) | .[] | "\(.mergeCommit.oid)\t\(.number)\t\(.title)"' > "$PR_DATA" 2>/dev/null; then |
| 143 | log_error "Failed to fetch PRs from milestone '${MILESTONE}'" |
| 144 | log_error "This could be due to:" |
| 145 | log_error " - Milestone does not exist" |
| 146 | log_error " - Network/authentication issues" |
| 147 | log_error " - Invalid milestone name format" |
| 148 | exit 1 |
| 149 | fi |
| 150 | |
| 151 | if [[ ! -s "$PR_DATA" ]]; then |
| 152 | log_warn "No merged PRs found for milestone '${MILESTONE}'" |
| 153 | exit 0 |
| 154 | fi |
| 155 | |
| 156 | TOTAL_PRS=$(wc -l < "$PR_DATA") |
| 157 | log_info "Found ${TOTAL_PRS} merged PR(s) in milestone" |
| 158 | echo "" |
| 159 | |
| 160 | # Find commits that are missing from current branch |
| 161 | MISSING_COMMITS=() |
| 162 | MISSING_INFO=() |
| 163 | |
| 164 | while IFS=$'\t' read -r sha pr_number title; do |
| 165 | # Skip if SHA is empty or null |
| 166 | if [[ -z "$sha" || "$sha" == "null" ]]; then |
| 167 | log_warn "PR #${pr_number} has no merge commit SHA, skipping" |
| 168 | continue |
| 169 | fi |
| 170 | |
| 171 | # Check if this commit is already in the current branch |
| 172 | if git merge-base --is-ancestor "$sha" HEAD 2>/dev/null; then |
| 173 | log_success "PR #${pr_number} already in branch: ${title:0:60}" |
| 174 | else |
| 175 | log_warn "PR #${pr_number} MISSING: ${title:0:60}" |
| 176 | MISSING_COMMITS+=("$sha") |
| 177 | MISSING_INFO+=("$sha PR #${pr_number}: ${title}") |
| 178 | fi |
| 179 | done < "$PR_DATA" |
| 180 | |
| 181 | echo "" |
| 182 | |
| 183 | if [[ ${#MISSING_COMMITS[@]} -eq 0 ]]; then |
| 184 | log_success "All PRs from milestone '${MILESTONE}' are already in the current branch!" |
| 185 | exit 0 |
| 186 | fi |
| 187 | |
| 188 | log_info "Found ${#MISSING_COMMITS[@]} missing commit(s) to cherry-pick" |
| 189 | echo "" |
| 190 | |
| 191 | # Output the cherry-pick commands |
| 192 | echo "==========================================" |
| 193 | echo "Cherry-pick commands (in chronological order):" |
| 194 | echo "==========================================" |
| 195 | echo "" |
| 196 | |
| 197 | for info in "${MISSING_INFO[@]}"; do |
| 198 | echo "# $info" |
| 199 | done |
| 200 | echo "" |
| 201 | |
| 202 | echo "# Run these commands to cherry-pick all missing commits:" |
| 203 | echo "git cherry-pick ${MISSING_COMMITS[*]}" |
| 204 | echo "" |
| 205 | |
| 206 | # Or one by one |
| 207 | echo "# Or cherry-pick one at a time:" |
| 208 | for sha in "${MISSING_COMMITS[@]}"; do |
| 209 | echo "git cherry-pick $sha" |
| 210 | done |
| 211 | echo "" |
| 212 | |
| 213 | # Execute if requested |
| 214 | if [[ "$DRY_RUN" == false ]]; then |
| 215 | echo "==========================================" |
| 216 | log_info "Executing cherry-picks..." |
| 217 | echo "==========================================" |
| 218 | |
| 219 | for i in "${!MISSING_COMMITS[@]}"; do |
| 220 | sha="${MISSING_COMMITS[$i]}" |
| 221 | info="${MISSING_INFO[$i]}" |
| 222 | |
| 223 | echo "" |
| 224 | log_info "Cherry-picking: $info" |
| 225 | |
| 226 | if git cherry-pick "$sha"; then |
| 227 | log_success "Successfully cherry-picked $sha" |
| 228 | else |
| 229 | log_error "Failed to cherry-pick $sha" |
| 230 | log_error "Resolve conflicts and run 'git cherry-pick --continue', or 'git cherry-pick --abort' to cancel" |
| 231 | exit 1 |
| 232 | fi |
| 233 | done |
| 234 | |
| 235 | echo "" |
| 236 | log_success "All cherry-picks completed successfully!" |
| 237 | else |
| 238 | echo "==========================================" |
| 239 | echo -e "${YELLOW}Dry run mode - no changes made${NC}" |
| 240 | echo "Run with --execute to perform the cherry-picks" |
| 241 | echo "==========================================" |
| 242 | fi |