#!/usr/bin/env bash # Shared helpers for scripts/shortcuts/review/*.sh # Source this file; do not execute directly. set -euo pipefail # Repo that hosts the PR. Override with REVIEW_REPO=owner/name if needed; # otherwise we derive it from the `upstream` remote, falling back to `origin`. resolve_repo() { if [ -n "${REVIEW_REPO:-}" ]; then echo "$REVIEW_REPO" return fi local url url=$(git remote get-url upstream 2>/dev/null || git remote get-url origin) # Accept git@github.com:owner/name(.git) and https://github.com/owner/name(.git) echo "$url" \ | sed -E 's#^git@github\.com:##; s#^https?://github\.com/##; s#\.git$##' } require() { local bin for bin in "$@"; do command -v "$bin" >/dev/null 2>&1 || { echo "[review] missing required tool: $bin" >&2 exit 1 } done } # Run the picked agent CLI on a single positional prompt. Each known agent # is launched in its equivalent "yolo" mode so headless / detached runs # (CI, background tasks, tmux workers) don't stall on per-tool permission # prompts that have no responder. Set REVIEW_AGENT_SAFE=1 to keep the # prompts (e.g. an interactive local run where you want to vet each step). # # Mirrors the precedent in bin/spawn-issue, which already passes # --dangerously-skip-permissions to its detached claude workers, and brings # the claude path in line with the existing codex / cursor handling. agent_exec() { local agent="$1" local prompt="$2" if [ "${REVIEW_AGENT_SAFE:-0}" = "1" ]; then case "$agent" in codex) exec codex "$prompt" ;; claude) exec claude "$prompt" ;; *) exec "$agent" "$prompt" ;; esac return fi case "$agent" in claude) exec claude --dangerously-skip-permissions "$prompt" ;; codex) exec codex --dangerously-bypass-approvals-and-sandbox "$prompt" ;; cursor|cursor-agent) exec cursor-agent --yolo "$prompt" ;; *) exec "$agent" "$prompt" ;; esac } gh_assign_self_issue() { local issue="$1" local repo="$2" if gh issue edit "$issue" -R "$repo" --add-assignee "@me" >/dev/null 2>&1; then info "assigned issue #$issue to @me" else warn "could not assign issue #$issue to @me; continuing" fi } gh_assign_self_pr() { local pr="$1" local repo="$2" if gh pr edit "$pr" -R "$repo" --add-assignee "@me" >/dev/null 2>&1; then info "assigned PR #$pr to @me" else warn "could not assign PR #$pr to @me; continuing" fi } # Summarize free-form text via a local LLM CLI (expects `-p `). # Usage: summarize_text # Tools used here: gemini (default for summaries), claude, or any CLI that # accepts `-p ""` and prints the response to stdout. # Special value `none` echoes input unchanged. summarize_text() { local tool="$1" local input="$2" if [ "$tool" = "none" ] || [ "$tool" = "raw" ]; then printf '%s' "$input" return fi require "$tool" local prompt prompt=$(cat <<'EOF' You are writing the body of a squash-merge commit. Summarize the PR changes below into 3-6 short bullet points. Rules: - Start each bullet with "- " and use imperative mood ("Add…", "Fix…", "Rename…"). - One line per bullet, under ~100 chars. - No headers, no code fences, no sign-offs, no Co-authored-by lines. - Do not include the PR number or title. - Output only the bullets, nothing else. PR content: --- EOF ) "$tool" -p "${prompt} ${input} ---" } require_pr_number() { if [ -z "${1:-}" ]; then echo "Usage: $(basename "$0") " >&2 exit 1 fi case "$1" in ''|*[!0-9]*) echo "[review] pr-number must be numeric, got: $1" >&2 exit 1 ;; esac } # ── Coloured output ──────────────────────────────────────────────── # Disable colour when stdout is not a terminal (piped / CI). if [ -t 1 ]; then _R=$'\033[0;31m' _G=$'\033[0;32m' _Y=$'\033[0;33m' _B=$'\033[1m' _0=$'\033[0m' else _R="" _G="" _Y="" _B="" _0="" fi pass() { printf '%s[PASS]%s %s\n' "$_G" "$_0" "$*"; } fail() { printf '%s[FAIL]%s %s\n' "$_R" "$_0" "$*" >&2; } warn() { printf '%s[WARN]%s %s\n' "$_Y" "$_0" "$*" >&2; } info() { printf '%s[INFO]%s %s\n' "$_B" "$_0" "$*"; } # Fetch PR head into local branch pr/, merge main in, wire upstream + # pushRemote so `git push` lands on the contributor's fork. sync_pr() { local pr="$1" local repo repo=$(resolve_repo) echo "[review] syncing main from upstream..." git checkout main git pull origin main git fetch upstream git merge upstream/main git submodule update --init --recursive local info head_repo head_branch local_branch info=$(gh pr view "$pr" -R "$repo" \ --json headRefName,headRepository,headRepositoryOwner) head_repo=$(echo "$info" | jq -r '.headRepositoryOwner.login + "/" + .headRepository.name') head_branch=$(echo "$info" | jq -r '.headRefName') local_branch="pr/$pr" echo "[review] PR #$pr -> $head_repo:$head_branch (local: $local_branch)" git fetch "https://github.com/${head_repo}.git" \ "+${head_branch}:${local_branch}" git checkout "$local_branch" echo "[review] merging main into $local_branch (conflicts will not abort)..." REVIEW_HAS_CONFLICTS=0 REVIEW_CONFLICT_FILES="" if ! git merge --no-edit main; then REVIEW_CONFLICT_FILES=$(git diff --name-only --diff-filter=U | sort -u) if [ -z "$REVIEW_CONFLICT_FILES" ]; then fail "git merge main failed for a non-conflict reason" return 1 fi echo "[review] ! conflicts detected in PR #$pr, continuing." REVIEW_HAS_CONFLICTS=1 fi # Prefer an existing SSH remote pointing at this fork to avoid https auth prompts. local remote_name="remote-$pr" local existing_ssh existing_ssh=$(git remote -v \ | awk -v repo="$head_repo" '$2 ~ ("[:/]" repo "(\\.git)?$") && $3 == "(fetch)" {print $1; exit}') if [ -n "$existing_ssh" ]; then remote_name="$existing_ssh" echo "[review] reusing remote '$remote_name' -> $(git remote get-url "$remote_name")" else local remote_url="https://github.com/${head_repo}.git" git remote add "$remote_name" "$remote_url" 2>/dev/null \ || git remote set-url "$remote_name" "$remote_url" fi git fetch "$remote_name" \ "+refs/heads/${head_branch}:refs/remotes/${remote_name}/${head_branch}" git branch --set-upstream-to="$remote_name/$head_branch" "$local_branch" git config "branch.${local_branch}.pushRemote" "$remote_name" git config "branch.${local_branch}.merge" "refs/heads/${head_branch}" echo "[review] upstream + pushRemote set to $remote_name/$head_branch" # Export for callers. REVIEW_PR="$pr" REVIEW_REPO_RESOLVED="$repo" REVIEW_LOCAL_BRANCH="$local_branch" REVIEW_HEAD_REPO="$head_repo" REVIEW_HEAD_BRANCH="$head_branch" REVIEW_PUSH_REMOTE="$remote_name" export REVIEW_PR REVIEW_REPO_RESOLVED REVIEW_LOCAL_BRANCH \ REVIEW_HEAD_REPO REVIEW_HEAD_BRANCH REVIEW_PUSH_REMOTE \ REVIEW_HAS_CONFLICTS REVIEW_CONFLICT_FILES }