1
0
Fork 0
mirror of https://code.forgejo.org/actions/cascading-pr synced 2025-03-14 22:36:58 +01:00
cascading-pr/cascading-pr.sh
Earl Warren 0c3c8b591b
allow running on a ref instead of a PR
When running from a PR, the ref is the head of the PR in the origin
repository. The PR is used to:

* display comments about the location of the destination PR
* asynchronously close / merge the destination PR when something
  happens in the origin PR

When only the ref is available, the destination PR must be closed and
the corresponding branch destroyed immediately after it concludes
because there is no convenient way to know what or when something
else will happen.
2024-01-03 16:12:23 +01:00

551 lines
14 KiB
Bash
Executable file

#!/bin/bash
# SPDX-License-Identifier: MIT
set -e
set -o posix
SELF=${BASH_SOURCE[0]}
SELF_DIR="$( cd "$( dirname "$SELF" )" && pwd )"
source $SELF_DIR/cascading-pr-lib.sh
trap "rm -fr $TMPDIR" EXIT
function repo_login() {
local direction="$1"
local repo=${options[${direction}_repo]}
(
export DOT=$TMPDIR/$repo
forgejo-curl.sh logout
forgejo-curl.sh --token "${options[${direction}_token]}" login "${options[${direction}_url]}"
)
}
function repo_curl() {
local repo=$1
shift
DOT=$TMPDIR/$repo forgejo-curl.sh "$@"
}
function default_branch() {
local direction=$1
repo_curl ${options[${direction}_repo]} api_json ${options[${direction}_api]} > $TMPDIR/$direction.json
jq --raw-output .default_branch < $TMPDIR/$direction.json
}
function destination_updated_at() {
local api
if ${options[destination_is_fork]} ; then
repo_curl ${options[destination_repo]} api_json ${options[destination_fork_api]} > $TMPDIR/updated_at.json
else
repo_curl ${options[destination_repo]} api_json ${options[destination_api]} > $TMPDIR/updated_at.json
fi
jq --raw-output .updated_at < $TMPDIR/updated_at.json
}
function exists_branch() {
local direction=$1
repo_curl ${options[${direction}_repo]} api_json ${options[${direction}_api]}/branches/${options[${direction}_head]} >& /dev/null
}
function delete_branch() {
local direction=$1
if ! $(exists_branch $direction) ; then
log_info "branch ${options[${direction}_head]} does not exists"
return
fi
repo_curl ${options[${direction}_repo]} api_json -X DELETE ${options[${direction}_api]}/branches/${options[${direction}_head]}
log_info "branch ${options[${direction}_head]} deleted"
}
function pr_origin_comment_body() {
echo "cascading-pr updated at ${options[destination_url]}/${options[destination_repo]}/pulls/$(pr_number destination)"
}
function comment_origin_pr() {
cat > $TMPDIR/data <<EOF
{
"body":"$(pr_origin_comment_body)"
}
EOF
repo_curl ${options[origin_repo]} api_json --data @$TMPDIR/data ${options[origin_api]}/issues/${options[origin_pr]}/comments
log_info "comment added to $(pr_url origin)"
}
function pr_destination_title() {
echo "cascading-pr from ${options[origin_url]}/${options[origin_repo]}/pulls/${options[origin_pr]}"
}
function pr_destination_body() {
echo "cascading-pr from ${options[origin_url]}/${options[origin_repo]}/pulls/${options[origin_pr]}"
}
function upsert_destination_pr() {
url=$(pr_url destination)
state=$(pr_state destination)
if test "$url" != "null" -a "$state" = "open"; then
log_info "an open PR already exists $url"
return
fi
if ${options[destination_is_fork]} ; then
head="$(owner ${options[destination_fork_repo]}):${options[destination_head]}"
else
head=${options[destination_head]}
fi
local title=$(pr_destination_title)
cat > $TMPDIR/data <<EOF
{
"title":"$(pr_destination_title)",
"body":"$(pr_destination_body)",
"base":"${options[destination_base]}",
"head":"$head"
}
EOF
retry repo_curl ${options[destination_repo]} api_json --data @$TMPDIR/data ${options[destination_api]}/pulls > $TMPDIR/destination-pr.json
log_info "PR created $(pr_url destination)"
}
function close_pr() {
local direction=destination
if test "$(pr_state ${direction})" = "open"; then
log_info "closing $(pr_url ${direction})"
local number=$(pr_number $direction)
repo_curl ${options[${direction}_repo]} api_json -X PATCH --data '{"state":"closed"}' ${options[${direction}_api]}/issues/$number
delete_branch ${direction}
else
log_info "no open PR found"
fi
}
function pr_get_origin() {
repo_curl ${options[origin_repo]} api_json ${options[origin_api]}/pulls/${options[origin_pr]} > $TMPDIR/origin-pr.json
}
function pr_get_destination() {
local title=$(pr_destination_title)
repo_curl ${options[destination_repo]} api --get --data state=open --data type=pulls --data-urlencode q="$title" ${options[destination_api]}/issues | jq --raw-output .[0] > $TMPDIR/destination-pr.json
}
function pr_get() {
local direction=$1
if ! test -f $TMPDIR/${direction}-pr.json; then
pr_get_$direction
fi
}
function pr() {
cat $TMPDIR/$1-pr.json
}
function pr_state() {
pr_get $1
pr $1 | jq --raw-output .state
}
function pr_url() {
pr_get $1
pr $1 | jq --raw-output .url
}
function pr_number() {
pr_get $1
pr $1 | jq --raw-output .number
}
function pr_merged() {
pr_get $1
pr $1 | jq --raw-output .merged
}
function pr_from_fork() {
pr_get $1
pr $1 | jq --raw-output .head.repo.fork
}
function git_clone() {
local direction=$1 url=$2
if ! test -d $TMPDIR/$direction; then
git -c credential.helper="store --file=$TMPDIR/$direction.git-credentials" clone $url $TMPDIR/$direction
fi
(
cd $TMPDIR/$direction
git config credential.helper "store --file=$TMPDIR/$direction.git-credentials"
git config user.email cascading-pr@example.com
git config user.name cascading-pr
)
}
function git_checkout() {
local direction=$1 ref="$2"
local remote=origin
(
cd $TMPDIR/$direction
if [[ "$ref" =~ ^refs/ ]] ; then
git fetch --update-head-ok ${remote} +$ref:$ref
else
ref=${remote}/$ref
fi
git checkout -b prbranch $ref
)
}
function git_remote() {
local direction=$1 remote=$2 url=$3
(
cd $TMPDIR/$direction
git remote add $remote $url
)
}
function git_reset_branch() {
local direction=$1 remote=$2 branch=$3
(
cd $TMPDIR/$direction
if git ls-remote --exit-code --heads ${remote} $branch ; then
git fetch --quiet ${remote} $branch
git reset --hard ${remote}/$branch
fi
)
}
function sha_pushed() {
local direction=$1
if test -f $TMPDIR/$direction.sha ; then
cat $TMPDIR/$direction.sha
fi
}
function destination_updated_at_changed() {
local before="$1"
local after="$(destination_updated_at)"
test "$before" != "$after"
}
function push() {
local remote=$1 branch=$2
(
cd $TMPDIR/destination
git add .
if git commit -m 'cascading-pr update'; then
local before=$(destination_updated_at)
sleep 1 # the resolution of the update time is one second
git push --force ${remote} prbranch:$branch
git rev-parse HEAD > ../destination.sha
retry destination_updated_at_changed "$before"
local after=$(destination_updated_at)
log_info "pushed"
else
log_info "nothing to push"
fi
)
}
function wait_destination_ci() {
local sha="$1"
local repo_api=${options[destination_url]}/api/v1/repos/${options[destination_repo]}
wait_success $repo_api $sha
}
function upsert_fork() {
if repo_curl ${options[destination_repo]} api_json ${options[destination_fork_api]} > $TMPDIR/fork.json 2> /dev/null ; then
if test "$(jq --raw-output .fork < $TMPDIR/fork.json)" != true ; then
log_error "the destination fork already exists but is not a fork ${options[destination_fork]}"
return 1
fi
local forked_from_repo=$(jq --raw-output .parent.full_name < $TMPDIR/fork.json)
if test "$forked_from_repo" != "${options[destination_repo]}" ; then
log_error "${options[destination_fork]} must be a fork of ${options[destination_repo]} but is a fork of $forked_from_repo instead"
return 1
fi
else
local fork_owner=$(owner ${options[destination_fork_repo]})
local data="{}"
if repo_curl ${options[destination_repo]} api_json ${options[destination_url]}/api/v1/orgs/${fork_owner} >& /dev/null ; then
data='{"organization":"'$fork_owner'"}'
fi
repo_curl ${options[destination_repo]} api_json --data "$data" ${options[destination_url]}/api/v1/repos/${options[destination_repo]}/forks
fi
}
function checkout() {
#
# origin
#
git_clone origin ${options[origin_clone]}
git_checkout origin "${options[origin_head]}"
#
# destination
#
git_clone destination ${options[destination_clone]}
git_checkout destination "${options[destination_base]}"
#
# fork
#
local head_remote=origin
if ${options[destination_is_fork]} ; then
upsert_fork
git_remote destination fork ${options[destination_fetch_fork]}
head_remote=fork
fi
git_reset_branch destination $head_remote "${options[destination_head]}"
}
function update() {
(
local update=${options[update]}
if ! [[ "$update" =~ ^/ ]] ; then
local d
if $(origin_has_pr) && $(pr_from_fork origin); then
local default_branch=$(default_branch origin)
log_info "PR is from a forked repository, using the default branch $default_branch to obtain the update script"
d=$TMPDIR/update
git -C $TMPDIR/origin worktree add $d $default_branch
else
d=$TMPDIR/origin
fi
update=$d/$update
fi
cd $TMPDIR
local origin_info
if $(origin_has_pr); then
origin_info=$TMPDIR/origin-pr.json
else
origin_info="${options[origin_ref]}"
fi
$update $TMPDIR/destination $TMPDIR/destination-pr.json $TMPDIR/origin $origin_info
)
local remote_head=origin
if ${options[destination_is_fork]} ; then
remote_head=fork
fi
push $remote_head ${options[destination_head]}
}
function set_git_url() {
local direction=$1 name=$2 repo=$3
local token=${options[${direction}_token]}
if [[ "$token" =~ ^@ ]] ; then
local file=${token##@}
(
echo -n ${options[${direction}_scheme]}://any:
cat $file
echo @${options[${direction}_host_port]}/$repo
) > $TMPDIR/$direction.git-credentials
else
echo ${options[${direction}_scheme]}://any:${options[${direction}_token]}@${options[${direction}_host_port]}/$repo > $TMPDIR/$direction.git-credentials
fi
options[$name]=${options[${direction}_scheme]}://${options[${direction}_host_port]}/$repo
}
function fork_sanity_check() {
local fork_repo=${options[destination_fork_repo]}
local repo=${options[destination_repo]}
if test "$(repository $fork_repo)" != "$(repository $repo)"; then
echo "$repo and its fork $fork_repo must have the same repository name (see https://codeberg.org/forgejo/forgejo/issues/1707)"
return 1
fi
}
function origin_sanity_check() {
pr_get_origin
}
function finalize_options() {
options[origin_api]=${options[origin_url]}/api/v1/repos/${options[origin_repo]}
options[origin_scheme]=$(scheme ${options[origin_url]})
options[origin_host_port]=$(host_port ${options[origin_url]})
set_git_url origin origin_clone ${options[origin_repo]}
set_origin_head
options[destination_api]=${options[destination_url]}/api/v1/repos/${options[destination_repo]}
options[destination_scheme]=$(scheme ${options[destination_url]})
options[destination_host_port]=$(host_port ${options[destination_url]})
set_git_url destination destination_clone ${options[destination_repo]}
options[destination_base]=${options[destination_branch]}
: ${options[prefix]:=${options[origin_repo]}}
set_destination_head
if test "${options[destination_fork_repo]}"; then
fork_sanity_check
options[destination_is_fork]=true
set_git_url destination destination_fetch_fork ${options[destination_fork_repo]}
options[destination_fork_api]=${options[destination_url]}/api/v1/repos/${options[destination_fork_repo]}
else
options[destination_is_fork]=false
fi
: ${options[close_merge]:=false}
}
function run() {
repo_login origin
repo_login destination
if $(origin_has_pr); then
run_origin_pr
else
run_origin_ref
fi
}
function run_origin_ref() {
log_info "update or create the cascade branch and PR"
checkout
update
local sha=$(sha_pushed destination)
if test "$sha" ; then
upsert_destination_pr
local status
if wait_destination_ci "$sha" ; then
log_info "cascade PR status successful"
status=0
else
log_info "cascade PR status failed"
status=1
fi
close_pr
return $status
fi
}
function run_origin_pr() {
local state=$(pr_state origin)
case "$state" in
open)
log_info "PR is open, update or create the cascade branch and PR"
checkout
update
local sha=$(sha_pushed destination)
if test "$sha" ; then
upsert_destination_pr
comment_origin_pr
wait_destination_ci "$sha"
fi
;;
closed)
if "$(pr_merged origin)"; then
if "${options[close_merge]}" ; then
log_info "PR is merged, close the cascade PR and remove the branch"
close_pr
else
log_info "PR was merged, update the cascade PR"
pr_get origin
pr_get destination
checkout
update
fi
else
log_info "PR is closed, close the cascade PR and remove the branch"
close_pr
fi
;;
*)
log_info "state '$state', do nothing"
;;
esac
}
function main() {
while true; do
case "$1" in
--verbose)
shift
verbose
;;
--debug)
shift
debug
;;
--origin-url)
shift
options[origin_url]=$1
shift
;;
--origin-repo)
shift
options[origin_repo]=$1
shift
;;
--origin-token)
shift
options[origin_token]=$1
shift
;;
--origin-pr)
shift
options[origin_pr]=$1
shift
;;
--origin-ref)
shift
options[origin_ref]=$1
shift
;;
--destination-url)
shift
options[destination_url]=$1
shift
;;
--destination-repo)
shift
options[destination_repo]=$1
shift
;;
--destination-fork-repo)
shift
options[destination_fork_repo]=$1
shift
;;
--destination-token)
shift
options[destination_token]=$1
shift
;;
--destination-branch)
shift
options[destination_branch]=$1
shift
;;
--update)
shift
options[update]=$1
shift
;;
--prefix)
shift
options[prefix]=$1
shift
;;
--close-merge)
shift
options[close_merge]=$1
shift
;;
*)
finalize_options
"${1:-run}"
return 0
;;
esac
done
}
dependencies
if echo "${@}" | grep --quiet -e '--debug' ; then
main "${@}"
else
stash_debug "${@}"
fi