fix(git-tools): bind PR edits to acting identity (#1080)
ci/woodpecker/pr/ci Pipeline was successful

This commit is contained in:
coder3
2026-08-12 21:08:34 -05:00
parent 25b055f105
commit ce6d735128
2 changed files with 172 additions and 135 deletions
+92 -66
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# pr-edit.sh - Edit a pull request on GitHub or Gitea # pr-edit.sh - Edit a pull request on GitHub or Gitea
# Usage: pr-edit.sh -n <pr_number> [-t <title>] [-b <body>] [-B <base>] [--draft|--ready] [--login <name>] # Usage: pr-edit.sh -n <pr_number> [-t <title>] [-b <body>] [-B <base>] [--draft|--ready] [--login <name>] [-r owner/repo] [-H host]
set -euo pipefail set -euo pipefail
@@ -14,6 +14,14 @@ BODY=""
BASE_BRANCH="" BASE_BRANCH=""
DRAFT_MODE="" DRAFT_MODE=""
LOGIN_OVERRIDE="" LOGIN_OVERRIDE=""
REPO_OVERRIDE=""
HOST_OVERRIDE=""
AUTH_CONFIG=""
cleanup() {
[[ -z "$AUTH_CONFIG" ]] || rm -f -- "$AUTH_CONFIG"
}
trap cleanup EXIT HUP INT TERM
usage() { usage() {
cat <<EOF cat <<EOF
@@ -28,7 +36,9 @@ Options:
-B, --base BRANCH New base branch -B, --base BRANCH New base branch
--draft Mark the pull request as draft --draft Mark the pull request as draft
--ready Mark the pull request ready for review --ready Mark the pull request ready for review
--login NAME Gitea tea login (must match the repository host) -l, --login NAME Gitea login (must authenticate as MOSAIC_GIT_IDENTITY)
-r, --repo OWNER/REPO Explicit target repository
-H, --host HOST Explicit Gitea host (required with --repo off-host)
-h, --help Show this help message -h, --help Show this help message
EOF EOF
exit "${1:-1}" exit "${1:-1}"
@@ -36,43 +46,21 @@ EOF
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
-n|--number) -n|--number) PR_NUMBER="${2:-}"; shift 2 ;;
PR_NUMBER="${2:-}" -t|--title) TITLE="${2:-}"; shift 2 ;;
shift 2 -b|--body) BODY="${2:-}"; shift 2 ;;
;; -B|--base) BASE_BRANCH="${2:-}"; shift 2 ;;
-t|--title)
TITLE="${2:-}"
shift 2
;;
-b|--body)
BODY="${2:-}"
shift 2
;;
-B|--base)
BASE_BRANCH="${2:-}"
shift 2
;;
--draft) --draft)
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; } [[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
DRAFT_MODE="draft" DRAFT_MODE="draft"; shift ;;
shift
;;
--ready) --ready)
[[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; } [[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
DRAFT_MODE="ready" DRAFT_MODE="ready"; shift ;;
shift -l|--login) LOGIN_OVERRIDE="${2:-}"; shift 2 ;;
;; -r|--repo) REPO_OVERRIDE="${2:-}"; shift 2 ;;
--login) -H|--host) HOST_OVERRIDE="${2:-}"; shift 2 ;;
LOGIN_OVERRIDE="${2:-}" -h|--help) usage 0 ;;
shift 2 *) echo "Unknown option: $1" >&2; usage ;;
;;
-h|--help)
usage 0
;;
*)
echo "Unknown option: $1" >&2
usage
;;
esac esac
done done
@@ -82,8 +70,16 @@ if [[ -z "$TITLE" && -z "$BODY" && -z "$BASE_BRANCH" && -z "$DRAFT_MODE" ]]; the
echo "Error: At least one edit option is required" >&2 echo "Error: At least one edit option is required" >&2
exit 1 exit 1
fi fi
[[ -z "$REPO_OVERRIDE" || "$REPO_OVERRIDE" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || {
echo "Error: --repo must be OWNER/REPO" >&2
exit 1
}
PLATFORM=$(detect_platform) if [[ -n "$HOST_OVERRIDE" || -n "$REPO_OVERRIDE" ]]; then
PLATFORM="gitea"
else
PLATFORM=$(detect_platform)
fi
case "$PLATFORM" in case "$PLATFORM" in
github) github)
@@ -102,44 +98,74 @@ case "$PLATFORM" in
fi fi
;; ;;
gitea) gitea)
HOST=$(get_remote_host) || { echo "Error: Could not resolve Gitea host from remote" >&2; exit 1; } IDENTITY="${MOSAIC_GIT_IDENTITY:-}"
REPO_SLUG=$(get_repo_slug) || { echo "Error: Could not resolve Gitea repo slug from remote" >&2; exit 1; } [[ -n "$IDENTITY" ]] || {
if [[ -n "$LOGIN_OVERRIDE" ]]; then echo "Error: MOSAIC_GIT_IDENTITY is required for a mutating Gitea operation" >&2
GITEA_LOGIN="$LOGIN_OVERRIDE"
else
GITEA_LOGIN=$(get_gitea_login) || { echo "Error: Could not resolve Gitea login for remote host" >&2; exit 1; }
fi
# Bind the credential to the explicitly selected login and repository host.
TOKEN=$(get_gitea_token_for_login "$GITEA_LOGIN" "$HOST") || {
echo "Error: Could not resolve token for Gitea login '$GITEA_LOGIN' on '$HOST'" >&2
exit 1 exit 1
} }
HOST="${HOST_OVERRIDE:-}"
if [[ -z "$HOST" ]]; then
HOST=$(get_remote_host) || {
echo "Error: Could not resolve Gitea host; pass --host with --repo" >&2
exit 1
}
fi
HOST="${HOST#http://}"; HOST="${HOST#https://}"; HOST="${HOST%%/*}"
REPO_SLUG="${REPO_OVERRIDE:-}"
if [[ -z "$REPO_SLUG" ]]; then
REPO_SLUG=$(get_repo_slug) || { echo "Error: Could not resolve Gitea repo slug from remote" >&2; exit 1; }
fi
if [[ -n "$LOGIN_OVERRIDE" ]]; then
GITEA_LOGIN_NAME="$LOGIN_OVERRIDE"
elif [[ -n "${GITEA_LOGIN:-}" ]]; then
GITEA_LOGIN_NAME="$GITEA_LOGIN"
else
echo "Error: --login (or GITEA_LOGIN) is required; refusing host-first login selection" >&2
exit 1
fi
TOKEN=$(get_gitea_token_for_login "$GITEA_LOGIN_NAME" "$HOST") || {
echo "Error: login '$GITEA_LOGIN_NAME' is not configured for target host '$HOST'" >&2
exit 1
}
AUTHENTICATED_USER=$(get_gitea_authenticated_user "$GITEA_LOGIN_NAME") || {
echo "Error: could not authenticate Gitea login '$GITEA_LOGIN_NAME'" >&2
exit 1
}
[[ "$AUTHENTICATED_USER" == "$IDENTITY" ]] || {
echo "Error: Gitea login '$GITEA_LOGIN_NAME' authenticates as '$AUTHENTICATED_USER', not MOSAIC_GIT_IDENTITY '$IDENTITY'" >&2
exit 1
}
AUTH_CONFIG=$(gitea_write_auth_config "$TOKEN") || {
echo "Error: could not stage private Gitea authentication" >&2
exit 1
}
unset TOKEN
API_BASE="https://${HOST}/api/v1/repos/${REPO_SLUG}"
# Preflight the explicit host/repo pair before any mutation. This prevents
# a slug inferred from one checkout being combined with another host.
curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$API_BASE" >/dev/null || {
echo "Error: target repository preflight failed for https://${HOST}/${REPO_SLUG}" >&2
exit 1
}
PAYLOAD=$(TITLE="$TITLE" BODY="$BODY" BASE_BRANCH="$BASE_BRANCH" DRAFT_MODE="$DRAFT_MODE" python3 - <<'PY' PAYLOAD=$(TITLE="$TITLE" BODY="$BODY" BASE_BRANCH="$BASE_BRANCH" DRAFT_MODE="$DRAFT_MODE" python3 - <<'PY'
import json import json
import os import os
payload = {} payload = {}
if os.environ["TITLE"]: if os.environ["TITLE"]: payload["title"] = os.environ["TITLE"]
payload["title"] = os.environ["TITLE"] if os.environ["BODY"]: payload["body"] = os.environ["BODY"]
if os.environ["BODY"]: if os.environ["BASE_BRANCH"]: payload["base"] = os.environ["BASE_BRANCH"]
payload["body"] = os.environ["BODY"] if os.environ["DRAFT_MODE"]: payload["draft"] = os.environ["DRAFT_MODE"] == "draft"
if os.environ["BASE_BRANCH"]:
payload["base"] = os.environ["BASE_BRANCH"]
if os.environ["DRAFT_MODE"]:
payload["draft"] = os.environ["DRAFT_MODE"] == "draft"
print(json.dumps(payload)) print(json.dumps(payload))
PY PY
) )
curl -fsS -X PATCH \ curl -fsS --config "$AUTH_CONFIG" -X PATCH \
-H "User-Agent: mosaic-pr-edit" \ -H "User-Agent: mosaic-pr-edit" -H "Content-Type: application/json" \
-H "Authorization: token ${TOKEN}" \ -d "$PAYLOAD" "$API_BASE/pulls/${PR_NUMBER}"
-H "Content-Type: application/json" \ echo "Updated Gitea pull request #$PR_NUMBER as '$AUTHENTICATED_USER'" >&2
-d "$PAYLOAD" \
"https://${HOST}/api/v1/repos/${REPO_SLUG}/pulls/${PR_NUMBER}"
echo "Updated Gitea pull request #$PR_NUMBER using login '$GITEA_LOGIN'" >&2
;;
*)
echo "Error: Could not detect git platform" >&2
exit 1
;; ;;
*) echo "Error: Could not detect git platform" >&2; exit 1 ;;
esac esac
@@ -1,112 +1,123 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Regression harness for PR editing, including Gitea draft/ready and login binding. # Regression harness for secret-safe, identity-bound PR editing and explicit targets.
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-edit}" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-edit}"
REPO_DIR="$WORK_DIR/repo" REPO_DIR="$WORK_DIR/repo"; BIN_DIR="$WORK_DIR/bin"; HOME_DIR="$WORK_DIR/home"
BIN_DIR="$WORK_DIR/bin" XDG_DIR="$WORK_DIR/xdg"; LOG_FILE="$WORK_DIR/calls.log"
HOME_DIR="$WORK_DIR/home" rm -rf "$WORK_DIR"; mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR" "$XDG_DIR/tea"
XDG_DIR="$WORK_DIR/xdg"
LOG_FILE="$WORK_DIR/calls.log"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR" "$XDG_DIR/tea"
git -C "$REPO_DIR" init -q git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git git -C "$REPO_DIR" remote add origin https://git.uscllc.com/other/wrong-checkout.git
git -C "$REPO_DIR" config mosaic.gitIdentity "" git -C "$REPO_DIR" config mosaic.gitIdentity ""
cat > "$XDG_DIR/tea/config.yml" <<'YAML' cat > "$XDG_DIR/tea/config.yml" <<'YAML'
logins: logins:
- name: usc - name: usc-coder3
url: https://git.uscllc.com url: https://git.uscllc.com
token: fixture-usc-token token: fixture-usc-token
- name: mosaicstack - name: same-host-other
url: https://git.uscllc.com
token: fixture-other-token
- name: mosaic-coder3
url: https://git.mosaicstack.dev url: https://git.mosaicstack.dev
token: fixture-mosaic-token token: fixture-mosaic-token
YAML YAML
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
[[ "$*" == "api --login usc-coder3 /user" ]] && { printf '{"login":"coder3"}\n'; exit 0; }
[[ "$*" == "api --login same-host-other /user" ]] && { printf '{"login":"other"}\n'; exit 0; }
[[ "$*" == "api --login mosaic-coder3 /user" ]] && { printf '{"login":"coder3"}\n'; exit 0; }
exit 1
SH
cat > "$BIN_DIR/curl" <<'SH' cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
printf 'curl' >> "$MOSAIC_TEST_LOG" printf 'curl' >> "$MOSAIC_TEST_LOG"; printf ' <%s>' "$@" >> "$MOSAIC_TEST_LOG"; printf '\n' >> "$MOSAIC_TEST_LOG"
printf ' <%s>' "$@" >> "$MOSAIC_TEST_LOG" [[ " $* " == *" -X PATCH "* ]] && printf '{"number":42,"draft":false}\n' || printf '{"name":"repo"}\n'
printf '\n' >> "$MOSAIC_TEST_LOG"
printf '{"number":42,"draft":false}\n'
SH SH
cat > "$BIN_DIR/gh" <<'SH' cat > "$BIN_DIR/gh" <<'SH'
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
printf 'gh' >> "$MOSAIC_TEST_LOG" printf 'gh' >> "$MOSAIC_TEST_LOG"; printf ' <%s>' "$@" >> "$MOSAIC_TEST_LOG"; printf '\n' >> "$MOSAIC_TEST_LOG"
printf ' <%s>' "$@" >> "$MOSAIC_TEST_LOG"
printf '\n' >> "$MOSAIC_TEST_LOG"
SH SH
chmod +x "$BIN_DIR/curl" "$BIN_DIR/gh" "$SCRIPT_DIR/pr-edit.sh" chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl" "$BIN_DIR/gh" "$SCRIPT_DIR/pr-edit.sh"
run_wrapper() { run_wrapper() {
( (cd "$REPO_DIR"; PATH="$BIN_DIR:$PATH" HOME="$HOME_DIR" XDG_CONFIG_HOME="$XDG_DIR" \
cd "$REPO_DIR" MOSAIC_TEST_LOG="$LOG_FILE" "$SCRIPT_DIR/pr-edit.sh" "$@")
PATH="$BIN_DIR:$PATH" \ }
HOME="$HOME_DIR" \ assert_no_secret() {
XDG_CONFIG_HOME="$XDG_DIR" \ ! grep -q 'fixture-.*-token' "$LOG_FILE" || { echo "Credential leaked into curl argv/log" >&2; exit 1; }
MOSAIC_TEST_LOG="$LOG_FILE" \
"$SCRIPT_DIR/pr-edit.sh" "$@"
)
} }
# The explicit target differs from CWD origin and must govern BOTH host and slug.
: > "$LOG_FILE" : > "$LOG_FILE"
# shellcheck disable=SC2016 # literal backticks prove argument-array body safety. # shellcheck disable=SC2016 # literal backticks prove argument-array body safety.
run_wrapper -n 42 --login usc --title 'New title' --body 'Body with `literal` bytes' --base develop --draft >/dev/null MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login mosaic-coder3 -r mosaicstack/stack \
-H git.mosaicstack.dev --title 'New title' --body 'Body with `literal` bytes' --base develop --draft >/dev/null
python3 - "$LOG_FILE" <<'PY' python3 - "$LOG_FILE" <<'PY'
import json import json, pathlib, sys
import pathlib lines = pathlib.Path(sys.argv[1]).read_text().splitlines()
import sys assert len(lines) == 2, lines
assert "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack" in lines[0], lines
line = pathlib.Path(sys.argv[1]).read_text() assert "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/42" in lines[1], lines
assert "-X> <PATCH>" in line, line assert "--config" in lines[0] and "--config" in lines[1], lines
assert "Authorization: token fixture-usc-token" in line, line assert "Authorization:" not in "\n".join(lines), lines
assert "https://git.uscllc.com/api/v1/repos/USC/uconnect/pulls/42" in line, line payload = lines[1].split(" <-d> <", 1)[1].split("> <https://", 1)[0]
payload = line.split(" <-d> <", 1)[1].split("> <https://", 1)[0] assert json.loads(payload) == {"title":"New title","body":"Body with `literal` bytes","base":"develop","draft":True}
data = json.loads(payload)
assert data == {
"title": "New title",
"body": "Body with `literal` bytes",
"base": "develop",
"draft": True,
}, data
PY PY
assert_no_secret
# Ready maps to false and still preflights before the write.
: > "$LOG_FILE" : > "$LOG_FILE"
run_wrapper -n 42 --login usc --ready >/dev/null MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login usc-coder3 -r USC/uconnect -H git.uscllc.com --ready >/dev/null
grep -q '"draft": false' "$LOG_FILE" grep -q '"draft": false' "$LOG_FILE"; assert_no_secret
# Identity is mandatory; no ambient/first-host login can write.
: > "$LOG_FILE" : > "$LOG_FILE"
if run_wrapper -n 42 --login mosaicstack --draft >/dev/null 2>&1; then if run_wrapper -n 42 --login usc-coder3 --draft >/dev/null 2>&1; then echo "Unset identity wrote" >&2; exit 1; fi
echo "Expected a login bound to another host to fail closed" >&2 [[ ! -s "$LOG_FILE" ]] || { echo "Unset identity reached curl" >&2; exit 1; }
exit 1
fi
[[ ! -s "$LOG_FILE" ]] || { echo "Mismatched login unexpectedly reached curl" >&2; exit 1; }
if run_wrapper -n 42 --draft --ready >/dev/null 2>&1; then # Explicit and ambient same-host wrong principals both refuse before preflight/write.
echo "Expected --draft and --ready to be mutually exclusive" >&2 for mode in explicit ambient; do
exit 1 : > "$LOG_FILE"
fi if [[ "$mode" == explicit ]]; then
if run_wrapper -n 42 >/dev/null 2>&1; then cmd=(--login same-host-other)
echo "Expected a no-op edit to fail" >&2 else
exit 1 cmd=(); export GITEA_LOGIN=same-host-other
fi fi
if ! run_wrapper --help 2>&1 | grep -q '^Usage:'; then if MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 "${cmd[@]}" -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then
echo "Expected --help to exit zero and print usage" >&2 echo "$mode wrong identity wrote" >&2; exit 1
exit 1 fi
fi unset GITEA_LOGIN
[[ ! -s "$LOG_FILE" ]] || { echo "$mode wrong identity reached curl" >&2; exit 1; }
done
# GitHub uses gh's supported edit and ready/undo commands rather than raw defaults. # Set identity with no explicit/ambient login refuses rather than selecting first host login.
: > "$LOG_FILE"
if MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then
echo "Missing login selected a principal" >&2; exit 1
fi
[[ ! -s "$LOG_FILE" ]] || { echo "Missing login reached curl" >&2; exit 1; }
# Cross-host credential fails before curl; explicit target preflight failure blocks PATCH.
: > "$LOG_FILE"
if MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login mosaic-coder3 -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then
echo "Cross-host login wrote" >&2; exit 1
fi
[[ ! -s "$LOG_FILE" ]] || { echo "Cross-host login reached curl" >&2; exit 1; }
if run_wrapper -n 42 --draft --ready >/dev/null 2>&1; then echo "Accepted conflicting modes" >&2; exit 1; fi
if run_wrapper -n 42 >/dev/null 2>&1; then echo "Accepted no-op edit" >&2; exit 1; fi
run_wrapper --help 2>&1 | grep -q '^Usage:'
# GitHub retains provider-native edit/readiness behavior.
git -C "$REPO_DIR" remote set-url origin https://github.com/acme/widgets.git git -C "$REPO_DIR" remote set-url origin https://github.com/acme/widgets.git
: > "$LOG_FILE" : > "$LOG_FILE"; run_wrapper -n 7 --title 'GitHub title' --draft >/dev/null
run_wrapper -n 7 --title 'GitHub title' --draft >/dev/null
grep -q 'gh <pr> <edit> <7> <--title> <GitHub title>' "$LOG_FILE" grep -q 'gh <pr> <edit> <7> <--title> <GitHub title>' "$LOG_FILE"
grep -q 'gh <pr> <ready> <7> <--undo>' "$LOG_FILE" grep -q 'gh <pr> <ready> <7> <--undo>' "$LOG_FILE"
echo "PR edit regression harness passed" echo "PR edit regression harness passed"