hack/close-manual-backport.bats
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bats
# Tests for hack/close-manual-backport.sh, which closes a pull request opened by
# hand against a release branch by someone outside the team.
#
# gh is stubbed: every call is recorded, the permission endpoint answers with
# $PERM_JSON (or fails under PERM_FAIL), and any other `gh api` with $PR_JSON.
# A case that expects the pull request to stay open asserts exactly which reads
# gh recorded, so a close that slips past a guard cannot go unseen.
#
# Run with: bats hack/close-manual-backport.bats
load test_helper
SCRIPT="${BATS_TEST_DIRNAME}/close-manual-backport.sh"
WF="${BATS_TEST_DIRNAME}/../.github/workflows/pr-labeler.yaml"
POLICY_URL="https://github.com/cozystack/cozystack/blob/main/docs/release/backports.md"
PERM_CALL="api repos/cozystack/cozystack/collaborators/someone/permission"
# The close-manual-backport job of pr-labeler.yaml, comment lines dropped so a
# setting demoted to a comment cannot satisfy an assertion.
guard_job() {
awk '/^ close-manual-backport:$/ { f = 1; next } f && /^ [^ ]/ { exit } f' "$WF" \
| grep -v '^[[:space:]]*#'
}
_stub_gh() {
mkdir -p "$1"
cat > "$1/gh" <<'EOF'
#!/bin/sh
printf '%s\n' "$*" >> "$GH_CALLS"
case "$1 $2" in
"api repos/"*/collaborators/*/permission)
if [ -n "${PERM_FAIL:-}" ]; then
echo 'gh: Not Found (HTTP 404)' >&2
exit 1
fi
cat "$PERM_JSON"
;;
"api "*) cat "$PR_JSON" ;;
esac
EOF
chmod +x "$1/gh"
}
setup() {
strict_setup
_stub_gh "${BATS_TEST_TMPDIR}/bin"
export PATH="${BATS_TEST_TMPDIR}/bin:$PATH"
export GH_CALLS="${BATS_TEST_TMPDIR}/calls"
export PR_JSON="${BATS_TEST_TMPDIR}/pr.json"
export PERM_JSON="${BATS_TEST_TMPDIR}/perm.json"
unset PERM_FAIL
set_permission read read
EVENT="${BATS_TEST_TMPDIR}/event.json"
# A run inside GitHub Actions has its own event here; never read it.
export GITHUB_EVENT_PATH="$EVENT"
: > "$GH_CALLS"
}
# The permission endpoint's answer: legacy permission, then role_name.
set_permission() {
printf '{"permission":"%s","role_name":"%s","user":{"login":"someone"}}\n' "$1" "$2" > "$PERM_JSON"
}
# Writes a pull_request_target event to $EVENT and its pull request object to
# $PR_JSON. Arguments: base ref, author association, author type, labels as a
# JSON array of names, and the author's login, someone by default. "-" leaves
# the association or the type out.
make_event() {
jq -n --arg base "$1" --arg assoc "$2" --arg type "$3" --argjson labels "$4" --arg login "${5:-someone}" '
({number: 42, state: "open",
base: {ref: $base, repo: {full_name: "cozystack/cozystack"}},
user: {login: $login, type: $type},
author_association: $assoc,
labels: ($labels | map({name: .}))}
| if $assoc == "-" then del(.author_association) else . end
| if $type == "-" then del(.user.type) else . end) as $pr
| {action: "opened", number: 42, pull_request: $pr,
repository: {full_name: "cozystack/cozystack"}}' > "$EVENT"
jq '.pull_request' "$EVENT" > "$PR_JSON"
}
assert_left_open() {
[ "$status" -eq 0 ] || { echo "FAIL: exit $status: $output"; false; }
if [ -s "$GH_CALLS" ]; then
echo "FAIL: gh was called:"
cat "$GH_CALLS"
false
fi
case "$output" in
*"leaving it open"*) ;;
*) echo "FAIL: no reason given: $output"; false ;;
esac
}
# Failed after the permission read, which is then the only gh call, with
# nothing closed and why said.
assert_failed_after_permission() {
[ "$status" -eq 1 ] || { echo "FAIL: exit $status: $output"; false; }
[ "$(cat "$GH_CALLS")" = "$PERM_CALL" ] || { echo "FAIL: gh calls:"; cat "$GH_CALLS"; false; }
case "$output" in
*"closing nothing"*) ;;
*) echo "FAIL: no reason given: $output"; false ;;
esac
}
# Left open after the permission read, which is then the only gh call.
assert_left_open_after_permission() {
[ "$status" -eq 0 ] || { echo "FAIL: exit $status: $output"; false; }
[ "$(cat "$GH_CALLS")" = "$PERM_CALL" ] || { echo "FAIL: gh calls:"; cat "$GH_CALLS"; false; }
case "$output" in
*"leaving it open"*) ;;
*) echo "FAIL: no reason given: $output"; false ;;
esac
}
@test "closes a pull request to release-1.6 from outside the team, linking the policy" {
for assoc in CONTRIBUTOR FIRST_TIME_CONTRIBUTOR FIRST_TIMER NONE; do
: > "$GH_CALLS"
make_event release-1.6 "$assoc" User '["kind/bug"]'
run "$SCRIPT" "$EVENT"
[ "$status" -eq 0 ] || { echo "FAIL: $assoc: exit $status: $output"; false; }
[ "$(wc -l < "$GH_CALLS" | tr -d ' ')" -eq 2 ] || { echo "FAIL: $assoc:"; cat "$GH_CALLS"; false; }
[ "$(sed -n 1p "$GH_CALLS")" = "$PERM_CALL" ]
sed -n 2p "$GH_CALLS" | grep -q '^pr close 42 --repo cozystack/cozystack --comment Thank you'
grep -qF "$POLICY_URL" "$GH_CALLS"
grep -qF 'a maintainer can reopen it.' "$GH_CALLS"
if grep -qF '—' "$GH_CALLS"; then echo "FAIL: the comment carries an em dash"; false; fi
done
}
@test "leaves OWNER, MEMBER and COLLABORATOR pull requests open" {
for assoc in OWNER MEMBER COLLABORATOR; do
: > "$GH_CALLS"
make_event release-1.6 "$assoc" User '[]'
run "$SCRIPT" "$EVENT"
assert_left_open
done
}
@test "leaves a bot's pull request open" {
make_event release-1.6 NONE Bot '[]'
run "$SCRIPT" "$EVENT"
assert_left_open
}
# The scheduled batch opens its pull requests as the cozystack-ci App, which
# GitHub can report as a contributor once it has commits here.
@test "leaves the batch's own pull request open" {
for assoc in NONE CONTRIBUTOR; do
: > "$GH_CALLS"
make_event release-1.7 "$assoc" Bot '[]' 'cozystack-ci[bot]'
run "$SCRIPT" "$EVENT"
assert_left_open
done
}
# No label keeps a pull request open: what keeps one is a maintainer
# reopening it, which the guard does not act on.
@test "closes a pull request whatever labels it carries" {
make_event release-1.6 CONTRIBUTOR User '["kind/bug","release-only","backport/include"]'
run "$SCRIPT" "$EVENT"
[ "$status" -eq 0 ] || { echo "FAIL: exit $status: $output"; false; }
sed -n 2p "$GH_CALLS" | grep -q '^pr close 42 --repo cozystack/cozystack --comment ' \
|| { echo "FAIL: not closed:"; cat "$GH_CALLS"; false; }
}
@test "acts only on an opened event, so a maintainer keeps a fix by reopening it" {
for action in reopened synchronize edited labeled ''; do
: > "$GH_CALLS"
make_event release-1.6 CONTRIBUTOR User '[]'
jq --arg a "$action" 'if $a == "" then del(.action) else .action = $a end' "$EVENT" > "$EVENT.tmp"
mv "$EVENT.tmp" "$EVENT"
run "$SCRIPT" "$EVENT"
assert_left_open
case "$output" in
*"not opened"*) ;;
*) echo "FAIL: reason does not name the event ($action): $output"; false ;;
esac
done
}
@test "leaves pull requests to main and to other branches open" {
for base in main feature-release-1.6 releases; do
: > "$GH_CALLS"
make_event "$base" CONTRIBUTOR User '[]'
run "$SCRIPT" "$EVENT"
assert_left_open
done
}
@test "does nothing when the author association is missing" {
make_event release-1.6 - User '[]'
run "$SCRIPT" "$EVENT"
assert_left_open
case "$output" in
*"no author association"*) ;;
*) echo "FAIL: reason does not name the association: $output"; false ;;
esac
}
@test "does nothing on an unknown association, a missing author type or an odd login" {
make_event release-1.6 SOMETHING_NEW User '[]'
run "$SCRIPT" "$EVENT"
assert_left_open
: > "$GH_CALLS"
make_event release-1.6 CONTRIBUTOR - '[]'
run "$SCRIPT" "$EVENT"
assert_left_open
# The login goes into the API path, so only a GitHub login shape is asked about.
: > "$GH_CALLS"
make_event release-1.6 CONTRIBUTOR User '[]'
jq '.pull_request.user.login = "someone/../../orgs/x"' "$EVENT" > "$EVENT.tmp"
mv "$EVENT.tmp" "$EVENT"
run "$SCRIPT" "$EVENT"
assert_left_open
}
# author_association shows an organization member whose membership is private
# as a CONTRIBUTOR. `permission` alone would also pass a triage member, which
# GitHub folds into read.
@test "a private member reported as CONTRIBUTOR stays open when their access is above read" {
for answer in "write write" "admin admin" "write maintain" "read triage" "read some-custom-role"; do
: > "$GH_CALLS"
set -- $answer
set_permission "$1" "$2"
make_event release-1.6 CONTRIBUTOR User '[]'
run "$SCRIPT" "$EVENT"
assert_left_open_after_permission
case "$output" in
*"permission on cozystack/cozystack is $1 (role $2)"*) ;;
*) echo "FAIL: reason does not name the access ($answer): $output"; false ;;
esac
done
}
@test "a failed or unreadable permission answer is an error and closes nothing" {
make_event release-1.6 CONTRIBUTOR User '[]'
export PERM_FAIL=1
run "$SCRIPT" "$EVENT"
assert_failed_after_permission
case "$output" in
*"cannot read someone's permission"*) ;;
*) echo "FAIL: reason does not name the failed read: $output"; false ;;
esac
unset PERM_FAIL
for answer in 'not json' '{}' '{"permission":"read"}' '{"role_name":"read"}' '{"permission":"read","role_name":null}' '[]'; do
: > "$GH_CALLS"
printf '%s\n' "$answer" > "$PERM_JSON"
run "$SCRIPT" "$EVENT"
assert_failed_after_permission
case "$output" in
*"does not give both permission and role_name"*) ;;
*) echo "FAIL: reason does not name the unreadable answer ($answer): $output"; false ;;
esac
done
}
@test "an author with read or no access is closed" {
for answer in "read read" "none none" "read none" "none read"; do
: > "$GH_CALLS"
set -- $answer
set_permission "$1" "$2"
make_event release-1.6 CONTRIBUTOR User '[]'
run "$SCRIPT" "$EVENT"
[ "$status" -eq 0 ] || { echo "FAIL: $answer: exit $status: $output"; false; }
[ "$(sed -n 1p "$GH_CALLS")" = "$PERM_CALL" ] || { echo "FAIL: $answer:"; cat "$GH_CALLS"; false; }
sed -n 2p "$GH_CALLS" | grep -q '^pr close 42 --repo cozystack/cozystack --comment ' \
|| { echo "FAIL: $answer: not closed:"; cat "$GH_CALLS"; false; }
done
}
@test "--dry-run never calls a write verb" {
make_event release-1.6 CONTRIBUTOR User '[]'
# From the event the one gh call allowed is the permission read.
run "$SCRIPT" --dry-run "$EVENT"
[ "$status" -eq 0 ]
[ "$(cat "$GH_CALLS")" = "$PERM_CALL" ] || { echo "FAIL: gh calls:"; cat "$GH_CALLS"; false; }
case "$output" in
*"would close PR #42 in cozystack/cozystack"*"$POLICY_URL"*) ;;
*) echo "FAIL: dry run did not report the close: $output"; false ;;
esac
# By number, the pull request read and the permission read.
: > "$GH_CALLS"
run "$SCRIPT" --dry-run --pr 42
[ "$status" -eq 0 ]
[ "$(cat "$GH_CALLS")" = "$(printf 'api repos/{owner}/{repo}/pulls/42\n%s' "$PERM_CALL")" ] \
|| { echo "FAIL: gh calls:"; cat "$GH_CALLS"; false; }
case "$output" in
*"would close PR #42"*) ;;
*) echo "FAIL: dry run did not report the close: $output"; false ;;
esac
}
@test "--pr reads the pull request with gh and closes it" {
make_event release-1.6 CONTRIBUTOR User '[]'
run "$SCRIPT" --pr 42
[ "$status" -eq 0 ]
[ "$(wc -l < "$GH_CALLS" | tr -d ' ')" -eq 3 ]
[ "$(sed -n 1p "$GH_CALLS")" = 'api repos/{owner}/{repo}/pulls/42' ]
[ "$(sed -n 2p "$GH_CALLS")" = "$PERM_CALL" ]
sed -n 3p "$GH_CALLS" | grep -q '^pr close 42 --repo cozystack/cozystack --comment '
}
@test "leaves a pull request that is already closed alone" {
make_event release-1.6 CONTRIBUTOR User '[]'
jq '.state = "closed"' "$PR_JSON" > "$PR_JSON.tmp"
mv "$PR_JSON.tmp" "$PR_JSON"
run "$SCRIPT" --pr 42
[ "$status" -eq 0 ]
[ "$(cat "$GH_CALLS")" = 'api repos/{owner}/{repo}/pulls/42' ]
case "$output" in
*"is not open"*) ;;
*) echo "FAIL: $output"; false ;;
esac
}
@test "an unreadable event is an error and closes nothing" {
printf 'not json' > "$EVENT"
run "$SCRIPT" "$EVENT"
[ "$status" -eq 1 ]
if [ -s "$GH_CALLS" ]; then echo "FAIL: gh was called:"; cat "$GH_CALLS"; false; fi
}
@test "a non-numeric --pr is a usage error" {
run "$SCRIPT" --pr '42; gh pr close 1'
[ "$status" -eq 2 ]
if [ -s "$GH_CALLS" ]; then echo "FAIL: gh was called:"; cat "$GH_CALLS"; false; fi
}
@test "pr-labeler runs the guard only on opened pull requests to release-*" {
job="$(guard_job)"
[ -n "$job" ]
printf '%s\n' "$job" | grep -qxF " github.event.action == 'opened' &&"
if printf '%s\n' "$job" | grep -qF 'reopened'; then echo "FAIL: the job runs on reopened"; false; fi
printf '%s\n' "$job" | grep -qF "startsWith(github.event.pull_request.base.ref, 'release-')"
printf '%s\n' "$job" | grep -qxF ' run: hack/close-manual-backport.sh "$GITHUB_EVENT_PATH"'
}
# pull_request_target hands a write token to a fork's pull request. The job is
# safe only while it holds no more than it needs and never reaches the head.
@test "the guard job holds the least token and never checks out the pull request" {
job="$(guard_job)"
[ -n "$job" ]
perms="$(printf '%s\n' "$job" | awk '/^ permissions:$/ { f = 1; next } f && /^ [^ ]/ { exit } f' \
| sed 's/[[:space:]]*#.*$//' | sort)"
[ "$perms" = "$(printf ' contents: read\n pull-requests: write')" ] || {
echo "FAIL: permissions are:"; printf '%s\n' "$perms"; false
}
# actions/checkout without a ref takes the event's own, which on
# pull_request_target is the base repository; a ref is the way to the head.
if printf '%s\n' "$job" | grep -qE '^[[:space:]]+ref:'; then echo "FAIL: the checkout names a ref"; false; fi
printf '%s\n' "$job" | grep -qxF ' persist-credentials: false'
# The token is the only expression: event fields reach the script through
# the JSON file, never through the shell.
[ "$(printf '%s\n' "$job" | grep -oF '${{' | wc -l | tr -d ' ')" -eq 1 ]
printf '%s\n' "$job" | grep -qxF ' GH_TOKEN: ${{ github.token }}'
}