mirror of
https://code.forgejo.org/actions/cascading-pr
synced 2025-03-14 22:36:58 +01:00

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.
551 lines
14 KiB
Bash
Executable file
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
|