diff --git a/etc/backport-ticket.sh b/etc/backport-ticket.sh new file mode 100755 index 000000000..70ad44de1 --- /dev/null +++ b/etc/backport-ticket.sh @@ -0,0 +1,125 @@ +#!/bin/bash + +start_dir=$(pwd) +script_dir=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")") + +# Source the functions file +functions="$script_dir/functions.sh" +[ -x "$functions" ] || { echo -e "\nERROR: $functions is not an executable file"; exit 1; } +source "$functions" + +test_filter="" +if [[ "$SPRING_MODULITH_BACKPORT_TICKET_TEST_MODE_ENABLED" == "true" ]]; then + echo -e "\nTest mode is enabled" + test_dir=$(getTestDir) + test_repo=$(getTestRepoName) + cd "$test_dir/$test_repo" || _exit 1 "Failed to change directory to $test_dir/$test_repo" + current_url=$(git remote get-url origin) + [[ "$?" == 0 ]] || _exit 1 "Failed to get current git url" + [[ "$current_url" != *"spring-projects"* ]] || _exit 1 "Remote URL cannot contain 'spring-projects' when test mode is enabled" + # Including a "since" date to enable test mode, wherein a copy of the repo is created (see test scripts) + # Issues created in the test env start with 1, which would be matched with old commits rather than new "test" commits + # Setting this date to "2023-11-01" will enable backporting open issue 345 if necessaey + # Once that issue is closd this date can also be safely set to June 5 (GH-659), Jul 5 (GH-704), or later if these are closed by then + test_filter="--since=\"2023-11-01\"" +fi + +# Check that at least two inputs were provided +# Format $ticketNumber $targetVersion1 $targetVersion2 ... $targetVersionN +isBlank "$1" || isBlank "$2" && _exit 1 "Two inputs are required: ticketNumber targetVersion[]" + +# Check that first input is valid +echo -e "\nChecking that the first input is valid" +number=$1 +isValidIssueNumber "$number" || _exit 1 "The provided issue number is invalid" + +# Check that second input is valid +echo -e "\nChecking that the second input is valid" +# Convert the second input to an array and check each element +versions=("${@:2}") +for version in "${versions[@]}"; do + isValidVersionNumber "$version" || _exit 1 "The provided version number [$version] is invalid" +done + +echo -e "\nChecking the state of the current branch" + +isDefaultBranch || _exit 1 "Current branch is not the default branch" +isCleanBranch || _exit 1 "Current branch is not clean" +# To repair, run: git cherry-pick --abort &>/dev/null; git fetch origin && git reset --hard origin/$(git symbolic-ref --short HEAD) && git clean -fd; git checkout main + +sourceGh=$(getGHCode "$number") +branch=$(git branch --show-current) + +echo -e "\nGathered working values:" +echo "sourceGh=$sourceGh" +echo "branch=$branch" +echo "test_filter=$test_filter" + +# The SHAs of all commits associated with the source ticket +echo -e "\nCapturing commits for $sourceGh:" +if [[ "$test_filter" == "" ]]; then + git log --grep="\<$sourceGh\>" --reverse + shas=$(git log --grep="\<$sourceGh\>" --reverse --format="%H") +else + git log --grep="\<$sourceGh\>" "$test_filter" --reverse + shas=$(git log --grep="\<$sourceGh\>" "$test_filter" --reverse --format="%H") +fi + +echo -e "\nshas=\n$shas" + +# For each of the target versions +for version in "${versions[@]}" +do + # Turn 1.5.6 into 1.5.x + targetBranch=$(getTargetBranch "$version") + + # Checkout target branch and cherry-pick commit + echo -e "\nChecking out target branch" + + git checkout $targetBranch + isCleanBranch || _exit 1 "Current branch is not clean" + # To repair, run: git cherry-pick --abort &>/dev/null; git fetch origin && git reset --hard origin/$(git symbolic-ref --short HEAD) && git clean -fd; git checkout main + + targetGh="" + targetMilestone=$(getTargetMilestone "$version") + + # Cherry-pick all previously found SHAs + while IFS= read -r sha + do + + echo -e "\nCherry-pick commit $sha from $branch" + git cherry-pick "$sha" + retVal=$? + [ "$retVal" == 0 ] || _exit 1 "Cherry-pick of commit $sha failed with return code $retVal" + + if isBlank "$targetGh"; then + targetCandidateNumbers=$(getIssueCandidatesForMilestone "$number" "$targetMilestone") + IFS=$'\n' read -rd '' -a array <<< "$targetCandidateNumbers" + countTargetCandidateNumbers=${#array[@]} + [ $countTargetCandidateNumbers -lt 2 ] || _exit 1 "Found multiple candidate target issues [$targetCandidateNumbers] for milestone [$targetMilestone]" + if [ $countTargetCandidateNumbers -eq 1 ]; then + #targetNumber=$(echo "$targetCandidateNumbers" | tr -d '\n') + targetNumber="$targetCandidateNumbers" + echo -e "\nRetrieved existing open target issue [$targetNumber] for milestone [$targetMilestone]" + isCleanIssue "$targetNumber" || _exit 1 "Target issue [$targetNumber] is not clean" + else + # count is 0, create a new issue + targetNumber=$(createIssueForMilestone "$number" "$targetMilestone") + isBlank "$targetNumber" && _exit 1 "Failed to create a new target issue for milestone [$targetMilestone]" + echo -e "\nCreated new target issue [$targetNumber] for milestone [$targetMilestone]" + fi + targetGh=$(getGHCode "$targetNumber") + fi + + # Replace ticket reference with new one + updateCommitMessage "$sourceGh" "$targetGh" + echo "Updated commit message" + + done <<< "$shas" + +done + +# Return to original branch +git checkout "$branch" + +cd "$start_dir" diff --git a/etc/functions.sh b/etc/functions.sh new file mode 100755 index 000000000..e7da2ff43 --- /dev/null +++ b/etc/functions.sh @@ -0,0 +1,216 @@ +# functions.sh + +# _exit should be called from the main script only. If called from +# another function, it will not cause the main script to exit +_exit() { + local exit_code="$1" + local message="$2" + if [ "$exit_code" -ne 0 ]; then + echo -e "\nERROR: $message" + else + echo -e "\n$message" + fi + echo "Exiting script [exit_code=$exit_code]" + exit "$exit_code" +} + +getTestDir() { + echo "/tmp" +} + +getTestRepoName() { + echo "spring-modulith-temp-copy" +} + +isBlank() { + [[ "$1" =~ ^[[:space:]]*$ ]] +} + +isValidIssueNumber() { + local issue="$1" + # Check if the provided number matches an existing issue number + # Note: The issue status should be "open" (backporting is done before an issue is closed) + # But it is not necessary to filter on status - it is preferable not to constrain + if ! gh issue list --limit 10000 --state "all" --json number --jq '.[].number' | grep -x -q "^$issue$"; then + echo "The provided issue number [$issue] does not match an existing GitHub issue number" + # output value + false + else + echo "The provided issue number [$issue] matches an existing GitHub issue number" + # output value + true + fi +} + +isValidVersionNumber() { + local version="$1" + # Check input format + local regex='^[0-9]+\.[0-9]+\.[0-9]+$' + if [[ "$version" =~ $regex ]]; then + echo "Version [$version] matches the required format" + # Check for branch + local targetBranch=$(getTargetBranch "$version") + local branch=$(git ls-remote --heads origin "$targetBranch") + if [[ -n "$branch" ]]; then + echo "Branch [$targetBranch] exists" + # Check for milestone + local targetMilestone=$(getTargetMilestone "$version") + local milestone=$(gh api repos/:owner/:repo/milestones --jq ".[] | select(.title == \"$targetMilestone\") | .title") + if [[ -n "$milestone" ]]; then + echo "Milestone [$targetMilestone] exists" + true + else + echo "Milestone [$targetMilestone] does not exist" + false + fi + else + echo "Branch [$targetBranch] does not exist" + false + fi + else + echo "Version [$version] does not match the required format [$regex]" + false + fi +} + +getTargetBranch() { + local version="$1" + local targetBranch="$(echo $version | grep -oE '^[0-9]+\.[0-9]+').x" + echo "$targetBranch" +} + +getTargetMilestone() { + local version="$1" + local targetMilestone="$version" + echo "$targetMilestone" +} + +isDefaultBranch() { + local current_branch=$(git rev-parse --abbrev-ref HEAD) + local default_branch=$(gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name') + if [ "$current_branch" != "$default_branch" ]; then + echo "Current branch [$current_branch] is NOT the default branch [$default_branch]" + # output value + false + else + echo "Current branch [$current_branch] is the default branch" + # output value + true + fi +} + +isCleanBranch() { + local branch=$(git symbolic-ref --short HEAD) + local remote_branch="origin/$branch" + + # Check for ongoing cherry-pick + if [ -d .git/sequencer ]; then + echo "Error: Ongoing cherry-pick operation detected." + # return code + return 1 + fi + + # Check for uncommitted changes + if ! git diff-index --quiet HEAD --; then + echo "Error: Uncommitted changes in the working directory." + # return code + return 1 + fi + + # Check for untracked files and directories + if [ -n "$(git clean -fdn)" ]; then + echo "Error: Untracked files or directories present." + # return code + return 1 + fi + + # Fetch latest changes from the remote + git fetch origin &>/dev/null + + # Check if local branch is ahead/behind the remote branch + local local_status=$(git rev-list --left-right --count ${branch}...${remote_branch}) + local ahead=$(echo $local_status | awk '{print $1}') + local behind=$(echo $local_status | awk '{print $2}') + + if [ "$ahead" -ne 0 ]; then + echo "Error: Local branch is ahead of the remote branch by $ahead commit(s)." + # return code + return 1 + elif [ "$behind" -ne 0 ]; then + echo "Error: Local branch is behind the remote branch by $behind commit(s)." + # return code + return 1 + fi + + # If all checks pass + echo "Local branch matches the remote branch." + # return code + return 0 +} + +getGHCode() { + echo "GH-$1" +} + +getIssueCandidatesForMilestone() { + local number="$1" + local milestone="$2" + + local json=$(gh issue view "$number" --json=title,labels) + local title=$(echo "$json" | jq -r '.title') + local labels=$(echo "$json" | jq -r '.labels[].name' | paste -sd ',' -) + local body="Back-port of $(getGHCode $number)." + + local targetCandidateNumbers=$(gh issue list --limit 10000 --state "open" --assignee "@me" --label "$labels" --milestone "$targetMilestone" --json number,title,body --jq ' + .[] | select(.title == "'"$title"'" and (.body | contains("'"$body"'")) ) | .number') + echo "$targetCandidateNumbers" +} + +createIssueForMilestone() { + local number="$1" + local milestone="$2" + + local json=$(gh issue view "$number" --json=title,labels) + local title=$(echo "$json" | jq -r '.title') + local labels=$(echo "$json" | jq -r '.labels[].name' | paste -sd ',' -) + local body="Back-port of $(getGHCode $number)." + + local targetNumber=$(gh issue create --assignee "@me" --label "$labels" --milestone "$targetMilestone" --title "$title" --body "$body" | awk -F '/' '{print $NF}') + echo "$targetNumber" +} + +isCleanIssue() { + local targetNumber="$1" + local targetGh=$(getGHCode "$targetNumber") + + # Check for commits mentioning the issue number + # $test_filter set globally in calling script + local commits + if [[ "$test_filter" == "" ]]; then + commits=$(git log --grep="\b$targetGh\b") + else + commits=$(git log --grep="\b$targetGh\b" "$test_filter") + fi + if [ -z "$commits" ]; then + # There are no commits that reference this issue + # output value + true + else + # output value + false + fi +} + +updateCommitMessage() { + local source="$1" + local target="$2" + local message=$(git log -1 --pretty=format:"%B" | sed "s/$source/$target/g") + if [[ $(echo $message | grep "$target") != "" ]]; then + # Update commit message to refer to new ticket + git commit --amend -m "$message" + [ "$?" -eq 0 ] || return 1 + return 0 + else + return 1 + fi +} diff --git a/etc/test/includes/copy-repo.sh b/etc/test/includes/copy-repo.sh new file mode 100755 index 000000000..1c6ab32c8 --- /dev/null +++ b/etc/test/includes/copy-repo.sh @@ -0,0 +1,87 @@ +#!/bin/bash + +# Check if values set globally by main script. If not, set here. +[ "$start_dir" != "" ] || start_dir=$(pwd) +[ "$script_dir" != "" ] || script_dir=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")") +[ "$functions" != "" ] || functions="$script_dir/../../functions.sh" + +# Source the functions file +[ -x "$functions" ] || { echo -e "\nERROR: $functions is not an executable file"; exit 1; } +source "$functions" + +test_dir=$(getTestDir) +test_repo=$(getTestRepoName) + +cd "$test_dir" || _exit 1 "Failed to change directory to $test_dir" +echo -e "\nIn $PWD" + +owner=$(gh api user --jq '.login') || _exit 1 "Failed to get the current GitHub owner" +if [[ "$owner" == "spring-projects" ]]; then + _exit 1 "GitHub owner cannot be 'spring-projects'" +fi + +# Create a new empty target repository on GitHub +echo -e "\nDeleting and recreating $owner/$test_repo" +gh repo delete "$owner/$test_repo" --yes +gh repo create "$owner/$test_repo" --public || _exit 1 "Failed to create an empty target repository on GitHub" + +# Clone the source repository +echo -e "\nCloning spring-projects/spring-modulith" +rm -rf "$test_repo" +gh repo clone spring-projects/spring-modulith "$test_repo" -- --no-tags || _exit 1 "Failed to clone repo spring-projects/spring-modulith" +cd "$test_repo" || _exit 1 "Failed to change directory into the cloned repo repository [$test_repo]" +echo -e "\nIn $(pwd)" +git rev-parse --is-inside-work-tree > /dev/null 2>&1 && git remote -v || _exit 1 "Not a git repository" + +original_branch=$(git branch --show-current) + +# Get list of desired branches +branches=() +regex='^[0-9]+\.[0-9]+\.x$' +branchCandidates=$(git branch -r | grep -v '\->' | sed 's/origin\///' | sed 's/^[ \t]*//;s/[ \t]*$//') +while IFS= read -r branchCandidate; do + if [[ "$branchCandidate" =~ $regex ]]; then + branches+=("$branchCandidate") + fi +done < <(echo "$branchCandidates") +[ ${#branches[@]} -ne 0 ] || _exit 1 "Failed to identify candidate branches" + +for branch in "${branches[@]}"; do + echo -e "\nSwitching branches for checkout" + git checkout --track "origin/$branch" || _exit 1 "Failed to check out branch $branch" +done + +echo -e "\nSwitching back to original branch" +git checkout "$original_branch" + +# Set origin url to new empty repo +echo -e "\nUpdate origin url to repo $test_repo" +current_url=$(git remote get-url origin) +if [[ "$current_url" == https* ]]; then + new_url="https://github.com/$owner/$test_repo.git" +else + new_url="git@github.com:$owner/$test_repo.git" +fi +git remote set-url origin "$new_url" + +# Double check that origin does not point to spring-projects +current_url=$(git remote get-url origin) +[[ "$?" == 0 ]] || _exit 1 "Failed to get current git url" +[[ "$current_url" != *"spring-projects"* ]] || _exit 1 "The remote URL still contains 'spring-projects'" + +# Push local branches to the new repository +branches=("$original_branch" "${branches[@]}") +for branch in "${branches[@]}"; do + echo -e "\nSwitching branches for push" + git checkout "$branch" || _exit 1 "Failed to check out branch $branch" + git push -u origin "$branch" || _exit 1 "Failed to push branch $branch" +done + +echo -e "\nSwitching back to default branch [$original_branch]" +git checkout "$original_branch" +gh repo edit --default-branch "$original_branch" + +# Clean up +cd "$start_dir" + +echo -e "\nSUCCESS: Created new repo [https://github.com/$owner/$test_repo], and cloned to $test_dir/$test_repo" diff --git a/etc/test/includes/create-issues-commits-milestones.sh b/etc/test/includes/create-issues-commits-milestones.sh new file mode 100755 index 000000000..1833cb107 --- /dev/null +++ b/etc/test/includes/create-issues-commits-milestones.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# Check if values set globally by main script. If not, set here. +[ "$start_dir" != "" ] || start_dir=$(pwd) +[ "$script_dir" != "" ] || script_dir=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")") +[ "$functions" != "" ] || functions="$script_dir/../../functions.sh" + +# Source the functions file +[ -x "$functions" ] || { echo -e "\nERROR: $functions is not an executable file"; exit 1; } +source "$functions" + +test_dir=$(getTestDir) +test_repo=$(getTestRepoName) + +cd "$test_dir/$test_repo" || _exit 1 "Failed to change directory to $test_dir/$test_repo" +echo -e "\nIn $(pwd)" +git rev-parse --is-inside-work-tree > /dev/null 2>&1 && git remote -v || _exit 1 "Not a git repository" + +echo -e "\nCreating new issue" +url=$(gh issue create --label "bug" --title "Buggy bug" --body "When I try to do this thing, it breaks" | grep "https") +number="$(echo "$url" | awk -F/ '{print $NF}')" +sourceGh=$(getGHCode "$number") +echo "Created issue $number [$sourceGh]" + +echo -e "\nCreating commits, some for issue $number [$sourceGh]" +for i in {1..3}; do + # Make issue-related commit + filename1="file-$number-$i.md" + echo "Part $i of the fix for issue $number" > $filename1 + git add $filename1 + git commit -m "$sourceGh - Fix part $i" + # Make non-related commit + filename2="file-0-$i.md" + echo "This line is NOT related to issue [$number-$i]" >> $filename2 + git add $filename2 + git commit -m "Updates not related to issue [$number-$i]" +done +# push changes +git push + +echo -e "\nCreating milestones" +milestones=$(gh api repos/spring-projects/spring-modulith/milestones --jq '.[].title') +echo "$milestones" | while IFS= read -r milestone; do + if [ "$milestone" != "" ]; then + result="$(gh api -X POST repos/:owner/:repo/milestones -f title="$milestone" -f state="open" -f description="" -f due_on=$(date -u -v +1y +"%Y-%m-%dT%H:%M:%SZ") 2>/dev/null)" + retVal="$?" + if [[ "$retVal" != 0 && "$(echo "$result" | jq -r '.errors[0].code')" == "already_exists" ]]; then + echo "Milestone [$milestone] already exists" + else + echo "Created milestone [$milestone]" + fi + fi +done + +# Clean up +cd "$start_dir" + +echo -e "\nSUCCESS: Created new issue [$url], with commits and milestones, using clone in $test_dir/$test_repo" diff --git a/etc/test/setup-test-env.sh b/etc/test/setup-test-env.sh new file mode 100755 index 000000000..2592afe50 --- /dev/null +++ b/etc/test/setup-test-env.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# main + +start_dir=$(pwd) +script_dir=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")") +functions="$script_dir/../functions.sh" + +source "$script_dir/includes/copy-repo.sh" +source "$script_dir/includes/create-issues-commits-milestones.sh" + +echo "DONE" \ No newline at end of file