Files
stack/packages/mosaic/framework/tools/git/pr-edit.sh
marcie e3f4a45b88
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/manual/ci Pipeline failed
framework tools/git: R3 --body-file across all nine body/comment carriers
--body-file <path> and --body-file - (stdin) load the body/comment from
a file, killing the fragile shell-quoting class for long Markdown
(Jason remediation R3). Contract: mutually exclusive with --body
(rc 2), unreadable file rc 2 naming the path, resolution runs AFTER
parsing and BEFORE the required-value checks. Full arm set on the
issue-comment suite (file, stdin, exclusive, missing); all nine
carrier suites and the readback suite green. Mirrored to brain.
2026-08-29 11:27:32 -05:00

214 lines
9.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# 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>] [-r owner/repo] [-H host]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=detect-platform.sh
source "$SCRIPT_DIR/detect-platform.sh"
PR_NUMBER=""
TITLE=""
BODY=""
BODY_FILE=""
BASE_BRANCH=""
DRAFT_MODE=""
LOGIN_OVERRIDE=""
REPO_OVERRIDE=""
HOST_OVERRIDE=""
AUTH_CONFIG=""
cleanup() {
[[ -z "$AUTH_CONFIG" ]] || rm -f -- "$AUTH_CONFIG"
}
terminate() {
local signal="$1"
trap - "$signal"
cleanup
kill -s "$signal" "$$"
}
trap cleanup EXIT
trap 'terminate HUP' HUP
trap 'terminate INT' INT
trap 'terminate TERM' TERM
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Edit a pull request on the current repository (Gitea or GitHub).
Options:
-n, --number NUMBER Pull request number (required)
-t, --title TITLE New title
-b, --body BODY New body/description
-B, --base BRANCH New base branch
--draft Mark the pull request as draft
--ready Mark the pull request ready for review
-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
EOF
exit "${1:-2}"
}
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
usage >&2
}
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--number) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; PR_NUMBER="${2:-}"; shift 2 ;;
-t|--title) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; TITLE="${2:-}"; shift 2 ;;
-b|--body) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; BODY="${2:-}"; shift 2 ;;
--body-file) [[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"; BODY_FILE="$2"; shift 2 ;;
-B|--base) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; BASE_BRANCH="${2:-}"; shift 2 ;;
--draft)
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 2; }
DRAFT_MODE="draft"; shift ;;
--ready)
[[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 2; }
DRAFT_MODE="ready"; shift ;;
-l|--login) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; LOGIN_OVERRIDE="${2:-}"; shift 2 ;;
-r|--repo) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; REPO_OVERRIDE="${2:-}"; shift 2 ;;
-H|--host) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; HOST_OVERRIDE="${2:-}"; shift 2 ;;
-h|--help) usage 0 ;;
*) echo "Unknown option: $1" >&2; usage ;;
esac
done
# R3 (2026-08-29): resolve --body-file into BODY (file or stdin '-');
# exclusive with an explicit --body value.
if [[ -n "$BODY_FILE" ]]; then
[[ -z "$BODY" ]] || usage_error "--body-file and --body are mutually exclusive"
if [[ "$BODY_FILE" == "-" ]]; then
BODY=$(cat) || usage_error "could not read body from stdin"
else
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
BODY=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
fi
fi
[[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 2; }
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 2; }
if [[ -z "$TITLE" && -z "$BODY" && -z "$BASE_BRANCH" && -z "$DRAFT_MODE" ]]; then
echo "Error: At least one edit option is required" >&2
exit 2
fi
[[ -z "$REPO_OVERRIDE" || "$REPO_OVERRIDE" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || {
echo "Error: --repo must be OWNER/REPO" >&2
exit 2
}
if [[ -n "$HOST_OVERRIDE" || -n "$REPO_OVERRIDE" ]]; then
PLATFORM="gitea"
else
PLATFORM=$(detect_platform)
fi
case "$PLATFORM" in
github)
[[ -z "$LOGIN_OVERRIDE" ]] || { echo "Error: --login is only valid for Gitea" >&2; exit 2; }
if [[ -n "$TITLE" || -n "$BODY" || -n "$BASE_BRANCH" ]]; then
CMD=(gh pr edit "$PR_NUMBER")
[[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
prov_rc=0
"${CMD[@]}" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
fi
if [[ "$DRAFT_MODE" == "draft" ]]; then
prov_rc=0
gh pr ready "$PR_NUMBER" --undo || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
elif [[ "$DRAFT_MODE" == "ready" ]]; then
prov_rc=0
gh pr ready "$PR_NUMBER" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
fi
;;
gitea)
IDENTITY="${MOSAIC_GIT_IDENTITY:-}"
[[ -n "$IDENTITY" ]] || {
echo "Error: MOSAIC_GIT_IDENTITY is required for a mutating Gitea operation" >&2
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
}
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"
# Resolve identity through the SAME private curl config used for the
# mutation. Tea login names are globally scoped and can be duplicated
# across hosts; a separate `tea api --login NAME` could validate another
# credential than this host-bound token.
AUTHENTICATED_USER=$(curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$API_BASE/user" \
| python3 -c 'import json,sys; value=json.load(sys.stdin).get("login"); print(value) if isinstance(value,str) and value else sys.exit(1)') || {
echo "Error: could not authenticate the host-bound credential for '$GITEA_LOGIN_NAME'" >&2
exit 1
}
[[ "$AUTHENTICATED_USER" == "$IDENTITY" ]] || {
echo "Error: host-bound credential authenticates as '$AUTHENTICATED_USER', not MOSAIC_GIT_IDENTITY '$IDENTITY'" >&2
exit 1
}
REPO_API="$API_BASE/repos/${REPO_SLUG}"
curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$REPO_API" >/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'
import json
import os
payload = {}
if os.environ["TITLE"]: payload["title"] = os.environ["TITLE"]
if os.environ["BODY"]: payload["body"] = os.environ["BODY"]
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))
PY
)
curl -fsS --config "$AUTH_CONFIG" -X PATCH \
-H "User-Agent: mosaic-pr-edit" -H "Content-Type: application/json" \
-d "$PAYLOAD" "$REPO_API/pulls/${PR_NUMBER}"
echo "Updated Gitea pull request #$PR_NUMBER as '$AUTHENTICATED_USER'" >&2
;;
*) echo "Error: Could not detect git platform" >&2; exit 1 ;;
esac