|
| 1 | +#!/bin/bash |
| 2 | +# version 2024.06.05 |
| 3 | +# backport the given merge SHA to the branch provided |
| 4 | + |
| 5 | +if ! [ -x "$(command -v jq)" ]; then |
| 6 | + echo |
| 7 | + echo 'Error: jq is not installed.' >&2 |
| 8 | + echo 'Please install package "jq" before using this script' |
| 9 | + echo |
| 10 | + exit 1 |
| 11 | +fi |
| 12 | + |
| 13 | +if ! [ -x "$(command -v curl)" ]; then |
| 14 | + echo |
| 15 | + echo 'Error: curl is not installed.' >&2 |
| 16 | + echo 'Please install package "curl" before using this script' |
| 17 | + echo |
| 18 | + exit 1 |
| 19 | +fi |
| 20 | + |
| 21 | +if [ "$#" -lt 2 ]; then |
| 22 | + echo |
| 23 | + echo "Illegal number of parameters" |
| 24 | + echo " $0 <merge/commit-sha> <targetBranchName>" |
| 25 | + echo " For example: $0 1234567 10.8" |
| 26 | + echo |
| 27 | + exit 1 |
| 28 | +fi |
| 29 | + |
| 30 | +commit=$1 |
| 31 | +targetBranch=$2 |
| 32 | +sourceBranch=$(git rev-parse --abbrev-ref HEAD) |
| 33 | + |
| 34 | +# check if the target branch exists on remote to avoid backporting to a non existing remote branch |
| 35 | +exists_in_remote=$(git ls-remote --heads origin ${targetBranch}) |
| 36 | +if [ -z "${exists_in_remote}" ]; then |
| 37 | + echo |
| 38 | + echo "Branch ${targetBranch} does not exist on remote. Create it first. Exiting" |
| 39 | + echo |
| 40 | + exit 1 |
| 41 | +fi |
| 42 | + |
| 43 | +# check if the target branch already exists locally |
| 44 | +exists_in_local=$(git branch --list ${targetBranch}) |
| 45 | +if [ -z "${exists_in_local}" ]; then |
| 46 | + echo |
| 47 | + echo "Branch ${targetBranch} does not exist locally. Make it available first. Exiting" |
| 48 | + echo |
| 49 | + exit 1 |
| 50 | +fi |
| 51 | + |
| 52 | +# check if the given merge commit exists in the actual checked out branch |
| 53 | +is_merged=$(git branch --contains $1 2>/dev/null | grep -oP '(?<=\*).*') |
| 54 | +if [ -z "${is_merged}" ]; then |
| 55 | + echo |
| 56 | + echo "${commit} does not exist because:" |
| 57 | + echo "- the PR has not been merged yet or" |
| 58 | + echo "- your actual backporting base branch ${sourceBranch} is not pulled/rebased." |
| 59 | + echo "Exiting" |
| 60 | + echo |
| 61 | + exit 1 |
| 62 | +fi |
| 63 | + |
| 64 | +# get the PR number from the merge commit |
| 65 | +# there can be a PR reference text in the commit like "fixes #1234". |
| 66 | +# we only need to take the last line which is then the real PR # the commit belongs to |
| 67 | +pullId=$(git log $1^! --oneline 2>/dev/null | tail -n 3 | grep -oP '(?<=#)[0-9]*' | tail -n 1) |
| 68 | + |
| 69 | +# get the repository from the given commit |
| 70 | +# remove prefix and suffix from the full url returned |
| 71 | +repository=$(git config --get remote.origin.url 2>/dev/null) |
| 72 | +repository=${repository#"https://github.com/"} |
| 73 | +repository=${repository%".git"} |
| 74 | + |
| 75 | +# get the list of commits in PR without any merge commit |
| 76 | +# $1^ means the first parent of the merge commit (that is passed in as $1). |
| 77 | +# because $1 is a "magically-generated" merge commit, it happily "jumps back" |
| 78 | +# to the point on the main branch just before where the PR was merged. |
| 79 | +# the commits from that point are exactly the list of individual |
| 80 | +# commits in the original PR. |
| 81 | +# --no-merges leaves out the merge commit itself, and we get just what we want |
| 82 | +commitList=$(git log --no-merges --reverse --format=format:%h $1^..$1) |
| 83 | + |
| 84 | +# get the request reset time window from github in epoch |
| 85 | +rateLimitReset=$(curl -iks https://api.github.com/users/zen 2>&1 | grep -im1 'X-Ratelimit-Reset:' | grep -o '[[:digit:]]*') |
| 86 | + |
| 87 | +# get the remaining requests in window from github |
| 88 | +rateLimitRemaining=$(curl -iks https://api.github.com/users/zen 2>&1 | grep -im1 'X-Ratelimit-Remaining:' | grep -o '[[:digit:]]*') |
| 89 | + |
| 90 | +# time remaining in epoch |
| 91 | +now=$(date +%s) |
| 92 | +((remaining=rateLimitReset-now)) |
| 93 | + |
| 94 | +# time remaining in HMS |
| 95 | +remaining=$(date -u -d @${remaining} +%H:%M:%S) |
| 96 | + |
| 97 | +# echo one time for a good rendering |
| 98 | +echo |
| 99 | + |
| 100 | +# check if there are commits to cherry pick and list them if present |
| 101 | +if [[ -z "${commitList}" ]]; then |
| 102 | + echo "There are no commit(s) to cherry pick. Exiting" |
| 103 | + echo |
| 104 | + exit 1 |
| 105 | +else |
| 106 | + lineCount=$(echo "${commitList}" | grep '' | wc -l) |
| 107 | + echo "${lineCount} commit(s) to be cherry picked:" |
| 108 | + echo |
| 109 | + echo "${commitList}" |
| 110 | + echo |
| 111 | +fi |
| 112 | + |
| 113 | +if [ ${rateLimitRemaining} -le 0 ]; then |
| 114 | + # do not continue if there are no remaining github requests available |
| 115 | + echo |
| 116 | + echo "You do not have enough github requests available to backport" |
| 117 | + echo "The current rate limit window resets in ${remaining}" |
| 118 | + echo |
| 119 | + exit 1 |
| 120 | +else |
| 121 | + # get the PR title, this is the only automated valid way to get the title |
| 122 | + pullTitle=$(curl https://api.github.com/repos/"${repository}"/pulls/"${pullId}" 2>/dev/null | jq '.title' | sed 's/^.//' | sed 's/.$//') |
| 123 | + # remove possible line breaks on any location in the string |
| 124 | + pullTitle=${pullTitle//$'\n'/} |
| 125 | +fi |
| 126 | + |
| 127 | +# build variables for later use |
| 128 | +newBranch="${targetBranch}-${commit}-${pullId}" |
| 129 | +message="[${targetBranch}] [PR ${pullId}] ${pullTitle}" |
| 130 | + |
| 131 | +# first check, if the source branch is clean and has no uncommited changes |
| 132 | +# in case this is true, checkout does not succeed and nothing needs to be done/switched |
| 133 | +# xargs removes any possible leading and trailing whitespaces |
| 134 | +is_source_branch_clean=$(git status --porcelain=v1 2>/dev/null | xargs) |
| 135 | +if [[ ! -z "${is_source_branch_clean}" ]]; then |
| 136 | + echo "Source branch ${sourceBranch} has probably uncommitted changes. Aborting." |
| 137 | + echo |
| 138 | + exit 1 |
| 139 | +fi |
| 140 | + |
| 141 | +# exit the script if any statement returns a non-true return value |
| 142 | +# means that all commands from now on must run successfully |
| 143 | +set -e |
| 144 | + |
| 145 | +# fetch branches and/or tags from one or more other repositories, along with the |
| 146 | +# objects necessary to complete their histories |
| 147 | +git fetch -p --quiet |
| 148 | + |
| 149 | +# checkout and rebase the target branch |
| 150 | +git checkout "${targetBranch}" --quiet |
| 151 | + |
| 152 | +# if everything is ok, then rebase the target branch |
| 153 | +git pull --rebase --quiet |
| 154 | + |
| 155 | +# create a new branch based on the target branch |
| 156 | +# the new branch name equals the new commit name |
| 157 | +git checkout -b "${newBranch}" "${targetBranch}" |
| 158 | + |
| 159 | +echo |
| 160 | + |
| 161 | +# cherry pick all commits from commitList |
| 162 | +lC=1 |
| 163 | +echo "${commitList}" | while IFS= read -r line; do |
| 164 | + # start cherry-picking |
| 165 | + echo "Cherry picking commit ${lC}: ${line}" |
| 166 | + |
| 167 | + # check if the commit to be cherry picked is already in the branch |
| 168 | + # this only works if the commit was cherry picked before! |
| 169 | + # else it will just try and continue. |
| 170 | + is_cherry_picked=$(git log --grep "${line}" 2>/dev/null) |
| 171 | + if [[ ! -z "${is_cherry_picked}" ]]; then |
| 172 | + echo |
| 173 | + echo "Commit ${line} has already been cherry picked, abort backporting." |
| 174 | + # go back to the base branch and delete the new branch with all its contents. |
| 175 | + git checkout --quiet "${sourceBranch}" |
| 176 | + git branch -D --quiet "${newBranch}" |
| 177 | + echo |
| 178 | + exit 1 |
| 179 | + fi |
| 180 | + |
| 181 | + # pull this commit into the new branch |
| 182 | + # --allow-empty is required if an empty commit is present like when when retriggering the CI. |
| 183 | + # if you do not want to use a default conflict resolution to take theirs |
| 184 | + # (help fix missing cherry picked commits or file renames) |
| 185 | + #git cherry-pick --allow-empty ${line} > /dev/null |
| 186 | + git cherry-pick --allow-empty -Xtheirs "${line}" > /dev/null |
| 187 | + lC=$(( ${lC} + 1 )) |
| 188 | +done |
| 189 | + |
| 190 | +echo |
| 191 | +echo "Committing changes" |
| 192 | +echo |
| 193 | + |
| 194 | +## rewrite the most recent commit message |
| 195 | +## the first -m creates the PR headline text |
| 196 | +## the second -m creates the PR message text |
| 197 | +git commit --allow-empty --quiet --amend -m "${message}" -m "Backport of PR #${pullId}" |
| 198 | + |
| 199 | +echo "Pushing: ${message}" |
| 200 | +echo |
| 201 | + |
| 202 | +git push --quiet -u origin "${newBranch}" |
| 203 | +git checkout --quiet "${sourceBranch}" |
| 204 | + |
| 205 | +# open the browser and prepare the pull request |
| 206 | +echo |
| 207 | +sleep 4 |
| 208 | +echo "Creating pull request for branch ${targetBranch} in ${repository}" |
| 209 | +xdg-open "https://github.com/${repository}/pull/new/${targetBranch}...${newBranch}" &>/dev/null |
0 commit comments