#!/bin/bash # pr-create.sh - Create pull requests on Gitea or GitHub # Usage: pr-create.sh -t "Title" [-b "Body"] [-B base] [-H head] [-l "labels"] [-m "milestone"] [--login ] # # Acting principal is resolved identity-first (#1280): an explicit --login # wins; otherwise MOSAIC_GIT_IDENTITY / per-worktree git config # mosaic.gitIdentity selects the principal when a per-slot token exists (and # the wrapper then creates the PR through the REST API with that identity's # token — tea is never invoked, so the tea login list cannot shadow the # requested principal); the tea login list is the LAST resort. A requested # identity with no per-slot token fails LOUD rather than writing under # whichever account tea happens to hold. set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/detect-platform.sh" # Default values TITLE="" BODY="" BASE_BRANCH="" HEAD_BRANCH="" LABELS="" MILESTONE="" DRAFT=false ISSUE="" # get_remote_host, get_gitea_token, get_repo_info, and get_gitea_repo_args are provided by detect-platform.sh # Acting-principal mode set in the Gitea branch below (from # resolve_gitea_principal): "login" when --login was given, "identity" when a # git identity bound, "default" otherwise. PRINCIPAL_MODE=login makes the API # arm resolve the --login principal's token too, so an explicit --login keeps # winning even on the tea-FAILURE fallback arm (otherwise the fallback would # silently re-resolve to the environment identity or shared credential). PRINCIPAL_MODE="" PRINCIPAL_NAME="" gitea_pr_create_api() { local host repo token url payload host=$(get_remote_host) || { echo "Error: could not determine remote host for API fallback" >&2 return 1 } repo=$(get_repo_info) || { echo "Error: could not determine repo owner/name for API fallback" >&2 return 1 } if [[ "$PRINCIPAL_MODE" == "login" ]]; then token=$(get_gitea_token_for_login "$PRINCIPAL_NAME" "$host") || { echo "Error: could not resolve a host-matched Gitea token for --login '$PRINCIPAL_NAME' on host '$host' (API path)" >&2 return 1 } else # Identity-first when MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity # is set (per-slot token, fail-loud on absence); shared default otherwise. token=$(get_gitea_token "$host") || { echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2 return 1 } fi if [[ -n "$LABELS" || -n "$MILESTONE" || "$DRAFT" == true ]]; then echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2 fi payload=$(TITLE="$TITLE" BODY="$BODY" HEAD_BRANCH="$HEAD_BRANCH" BASE_BRANCH="$BASE_BRANCH" python3 - <<'PY' import json import os payload = { "title": os.environ["TITLE"], "head": os.environ["HEAD_BRANCH"], "base": os.environ["BASE_BRANCH"] or "main", } body = os.environ.get("BODY", "") if body: payload["body"] = body print(json.dumps(payload)) PY ) url="https://${host}/api/v1/repos/${repo}/pulls" curl -fsS -X POST \ -H "User-Agent: curl/8" \ -H "Authorization: token ${token}" \ -H "Content-Type: application/json" \ -d "$payload" \ "$url" } usage() { cat <&2 usage ;; esac done # If no title but issue provided, generate title if [[ -z "$TITLE" ]] && [[ -n "$ISSUE" ]]; then TITLE="Fixes #$ISSUE" fi if [[ -z "$TITLE" ]]; then echo "Error: Title is required (-t) or provide an issue (-i)" >&2 usage fi # Default head branch to current branch if [[ -z "$HEAD_BRANCH" ]]; then HEAD_BRANCH=$(git branch --show-current) fi # Add issue reference to body if provided if [[ -n "$ISSUE" ]]; then if [[ -n "$BODY" ]]; then BODY="$BODY Fixes #$ISSUE" else BODY="Fixes #$ISSUE" fi fi PLATFORM=$(detect_platform) case "$PLATFORM" in github) CMD=(gh pr create --title "$TITLE") [[ -n "$BODY" ]] && CMD+=(--body "$BODY") [[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH") [[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH") [[ -n "$LABELS" ]] && CMD+=(--label "$LABELS") [[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE") [[ "$DRAFT" == true ]] && CMD+=(--draft) "${CMD[@]}" ;; gitea) # Resolve the acting principal identity-first (#1280). The tea login # list is the LAST resort: it knows nothing about which seat is calling, # and a login resolved from it first is what attributed PRs to the wrong # account even when MOSAIC_GIT_IDENTITY was set. principal_host=$(get_remote_host 2>/dev/null || true) if ! principal_resolved="$(resolve_gitea_principal "${LOGIN_OVERRIDE:-}" "$principal_host")"; then # resolve_gitea_principal already printed the fail-loud diagnostic. exit 1 fi PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)" PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)" if [[ "$PRINCIPAL_MODE" == "identity" ]]; then # HAPPY PATH for a requested identity: the per-slot token IS the # credential, so create through the REST API directly and never # invoke tea — the identity arm must be REACHED, not sit behind a # tea failure (#1280). Fail-loud on a missing slot already happened # in resolve_gitea_principal. gitea_pr_create_api exit $? fi # tea pull create syntax. Always pass --repo because tea repo inference # is unreliable in Mosaic worktrees/profile shells. Use arrays instead # of eval so markdown backticks/body content are not shell-executed. REPO_SLUG=$(get_repo_slug) if [[ "$PRINCIPAL_MODE" == "login" ]]; then GITEA_LOGIN_NAME="$PRINCIPAL_NAME" else GITEA_LOGIN_NAME=$(get_gitea_login) || { echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2 gitea_pr_create_api exit $? } fi if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2 gitea_pr_create_api exit $? fi REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME") CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE") [[ -n "$BODY" ]] && CMD+=(--description "$BODY") [[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH") [[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH") # Handle labels for tea if [[ -n "$LABELS" ]]; then # tea may use --labels flag CMD+=(--labels "$LABELS") fi # Handle milestone for tea if [[ -n "$MILESTONE" ]]; then MILESTONE_ID=$(tea milestones list "${REPO_ARGS[@]}" 2>/dev/null | grep -E "^\s*[0-9]+" | grep "$MILESTONE" | awk '{print $1}' | head -1) if [[ -n "$MILESTONE_ID" ]]; then CMD+=(--milestone "$MILESTONE_ID") else echo "Warning: Could not find milestone '$MILESTONE', creating without milestone" >&2 fi fi # Note: tea may not support --draft flag in all versions if [[ "$DRAFT" == true ]]; then echo "Note: Draft PR may not be supported by your tea version" >&2 fi if "${CMD[@]}"; then exit 0 fi echo "Warning: tea pr create failed, trying Gitea API fallback..." >&2 gitea_pr_create_api ;; *) echo "Error: Could not detect git platform" >&2 exit 1 ;; esac