#!/usr/bin/env bash # pr-edit.sh - Edit a pull request on GitHub or Gitea # Usage: pr-edit.sh -n [-t ] [-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="" 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:-1}" } while [[ $# -gt 0 ]]; do case "$1" in -n|--number) PR_NUMBER="${2:-}"; shift 2 ;; -t|--title) TITLE="${2:-}"; shift 2 ;; -b|--body) BODY="${2:-}"; shift 2 ;; -B|--base) BASE_BRANCH="${2:-}"; shift 2 ;; --draft) [[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; } DRAFT_MODE="draft"; shift ;; --ready) [[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; } DRAFT_MODE="ready"; shift ;; -l|--login) LOGIN_OVERRIDE="${2:-}"; shift 2 ;; -r|--repo) REPO_OVERRIDE="${2:-}"; shift 2 ;; -H|--host) HOST_OVERRIDE="${2:-}"; shift 2 ;; -h|--help) usage 0 ;; *) echo "Unknown option: $1" >&2; usage ;; esac done [[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 1; } [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 1; } if [[ -z "$TITLE" && -z "$BODY" && -z "$BASE_BRANCH" && -z "$DRAFT_MODE" ]]; then echo "Error: At least one edit option is required" >&2 exit 1 fi [[ -z "$REPO_OVERRIDE" || "$REPO_OVERRIDE" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || { echo "Error: --repo must be OWNER/REPO" >&2 exit 1 } 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 1; } 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") "${CMD[@]}" fi if [[ "$DRAFT_MODE" == "draft" ]]; then gh pr ready "$PR_NUMBER" --undo elif [[ "$DRAFT_MODE" == "ready" ]]; then gh pr ready "$PR_NUMBER" 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