From 4f0d3e6e25cfba851523cfde246137810699e334 Mon Sep 17 00:00:00 2001 From: coder3 Date: Wed, 12 Aug 2026 14:41:29 -0500 Subject: [PATCH] feat(git-tools): add pull request edit wrapper (#1080) --- .../mosaic/framework/tools/git/pr-edit.sh | 145 ++++++++++++++++++ .../tools/git/test-help-exit-code.sh | 5 +- .../framework/tools/git/test-pr-edit.sh | 112 ++++++++++++++ 3 files changed, 260 insertions(+), 2 deletions(-) create mode 100755 packages/mosaic/framework/tools/git/pr-edit.sh create mode 100755 packages/mosaic/framework/tools/git/test-pr-edit.sh diff --git a/packages/mosaic/framework/tools/git/pr-edit.sh b/packages/mosaic/framework/tools/git/pr-edit.sh new file mode 100755 index 00000000..42133724 --- /dev/null +++ b/packages/mosaic/framework/tools/git/pr-edit.sh @@ -0,0 +1,145 @@ +#!/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>] + +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="" + +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 + --login NAME Gitea tea login (must match the repository 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 + ;; + --login) + LOGIN_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 + +PLATFORM=$(detect_platform) + +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) + HOST=$(get_remote_host) || { echo "Error: Could not resolve Gitea host from remote" >&2; exit 1; } + REPO_SLUG=$(get_repo_slug) || { echo "Error: Could not resolve Gitea repo slug from remote" >&2; exit 1; } + if [[ -n "$LOGIN_OVERRIDE" ]]; then + 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 + } + 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 -X PATCH \ + -H "User-Agent: mosaic-pr-edit" \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -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 + ;; +esac diff --git a/packages/mosaic/framework/tools/git/test-help-exit-code.sh b/packages/mosaic/framework/tools/git/test-help-exit-code.sh index e6e3483f..bd61f15a 100755 --- a/packages/mosaic/framework/tools/git/test-help-exit-code.sh +++ b/packages/mosaic/framework/tools/git/test-help-exit-code.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Regression harness for #701: -h/--help must exit 0, bad args must still exit nonzero. # -# Covers the 7 wrappers whose usage() previously hard-coded `exit 1`, so every +# Covers wrappers whose usage() previously hard-coded `exit 1`, so every # --help invocation exited nonzero and logged a phantom isError across fleet lanes. # Asserts, per wrapper: # 1. `--help` exits 0 and prints usage. @@ -18,6 +18,7 @@ WRAPPERS=( issue-list.sh milestone-create.sh pr-create.sh + pr-edit.sh pr-list.sh pr-merge.sh ) @@ -47,7 +48,7 @@ for wrapper in "${WRAPPERS[@]}"; do done if [[ "$fail" -eq 0 ]]; then - echo "help-exit-code regression passed (7/7 wrappers)" + echo "help-exit-code regression passed (8/8 wrappers)" fi exit "$fail" diff --git a/packages/mosaic/framework/tools/git/test-pr-edit.sh b/packages/mosaic/framework/tools/git/test-pr-edit.sh new file mode 100755 index 00000000..3a8f9a6d --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-pr-edit.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Regression harness for PR editing, including Gitea draft/ready and login binding. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-edit}" +REPO_DIR="$WORK_DIR/repo" +BIN_DIR="$WORK_DIR/bin" +HOME_DIR="$WORK_DIR/home" +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" remote add origin https://git.uscllc.com/USC/uconnect.git +git -C "$REPO_DIR" config mosaic.gitIdentity "" + +cat > "$XDG_DIR/tea/config.yml" <<'YAML' +logins: +- name: usc + url: https://git.uscllc.com + token: fixture-usc-token +- name: mosaicstack + url: https://git.mosaicstack.dev + token: fixture-mosaic-token +YAML + +cat > "$BIN_DIR/curl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf 'curl' >> "$MOSAIC_TEST_LOG" +printf ' <%s>' "$@" >> "$MOSAIC_TEST_LOG" +printf '\n' >> "$MOSAIC_TEST_LOG" +printf '{"number":42,"draft":false}\n' +SH + +cat > "$BIN_DIR/gh" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf 'gh' >> "$MOSAIC_TEST_LOG" +printf ' <%s>' "$@" >> "$MOSAIC_TEST_LOG" +printf '\n' >> "$MOSAIC_TEST_LOG" +SH +chmod +x "$BIN_DIR/curl" "$BIN_DIR/gh" "$SCRIPT_DIR/pr-edit.sh" + +run_wrapper() { + ( + cd "$REPO_DIR" + PATH="$BIN_DIR:$PATH" \ + HOME="$HOME_DIR" \ + XDG_CONFIG_HOME="$XDG_DIR" \ + MOSAIC_TEST_LOG="$LOG_FILE" \ + "$SCRIPT_DIR/pr-edit.sh" "$@" + ) +} + +: > "$LOG_FILE" +# 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 +python3 - "$LOG_FILE" <<'PY' +import json +import pathlib +import sys + +line = pathlib.Path(sys.argv[1]).read_text() +assert "-X> <PATCH>" in line, line +assert "Authorization: token fixture-usc-token" in line, line +assert "https://git.uscllc.com/api/v1/repos/USC/uconnect/pulls/42" in line, line +payload = line.split(" <-d> <", 1)[1].split("> <https://", 1)[0] +data = json.loads(payload) +assert data == { + "title": "New title", + "body": "Body with `literal` bytes", + "base": "develop", + "draft": True, +}, data +PY + +: > "$LOG_FILE" +run_wrapper -n 42 --login usc --ready >/dev/null +grep -q '"draft": false' "$LOG_FILE" + +: > "$LOG_FILE" +if run_wrapper -n 42 --login mosaicstack --draft >/dev/null 2>&1; then + echo "Expected a login bound to another host to fail closed" >&2 + 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 + echo "Expected --draft and --ready to be mutually exclusive" >&2 + exit 1 +fi +if run_wrapper -n 42 >/dev/null 2>&1; then + echo "Expected a no-op edit to fail" >&2 + exit 1 +fi +if ! run_wrapper --help 2>&1 | grep -q '^Usage:'; then + echo "Expected --help to exit zero and print usage" >&2 + exit 1 +fi + +# GitHub uses gh's supported edit and ready/undo commands rather than raw defaults. +git -C "$REPO_DIR" remote set-url origin https://github.com/acme/widgets.git +: > "$LOG_FILE" +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> <ready> <7> <--undo>' "$LOG_FILE" + +echo "PR edit regression harness passed"