mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
292 lines
10 KiB
Bash
Executable File
292 lines
10 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# merge.sh <pr-number> [--squash|--merge|--rebase] [--dry-run] [--summary-llm <tool>]
|
|
# Merge a PR via gh. Defaults to --squash.
|
|
#
|
|
# For --squash we rewrite the commit body:
|
|
# - summarize the PR body + commit messages with the summary LLM
|
|
# (default: gemini; use `none` to skip and keep the raw PR body)
|
|
# - drop any Co-authored-by lines mentioning copilot / codex / cursor / claude
|
|
# - add the current `git config user.name <user.email>` as a co-author
|
|
# --merge and --rebase keep the original commits as-is.
|
|
#
|
|
# --dry-run prints the squash subject + body that would be used and exits
|
|
# without calling `gh pr merge`. Ignored for --merge / --rebase.
|
|
|
|
set -euo pipefail
|
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
# shellcheck source=lib.sh
|
|
source "$here/lib.sh"
|
|
|
|
require git gh jq
|
|
require_pr_number "${1:-}"
|
|
|
|
pr="$1"
|
|
strategy="--squash"
|
|
dry_run=0
|
|
force=0
|
|
admin=0
|
|
auto=0
|
|
summary_llm="gemini"
|
|
shift
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--squash|--merge|--rebase) strategy="$1"; shift ;;
|
|
--dry-run|-n) dry_run=1; shift ;;
|
|
--force|-f) force=1; shift ;;
|
|
--admin) admin=1; shift ;;
|
|
--auto) auto=1; shift ;;
|
|
--summary-llm) summary_llm="${2:?--summary-llm requires a value}"; shift 2 ;;
|
|
--summary-llm=*) summary_llm="${1#*=}"; shift ;;
|
|
*)
|
|
echo "[review] unknown arg: $1 (expected --squash|--merge|--rebase|--dry-run|--force|--admin|--auto|--summary-llm)" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [ "$admin" = "1" ] && [ "$auto" = "1" ]; then
|
|
echo "[review] --admin and --auto are mutually exclusive" >&2
|
|
exit 1
|
|
fi
|
|
|
|
repo=$(resolve_repo)
|
|
|
|
echo "[review] PR #$pr status on $repo:"
|
|
pr_status_json=$(gh pr view "$pr" -R "$repo" \
|
|
--json state,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup)
|
|
jq '{state, mergeable, mergeStateStatus, reviewDecision,
|
|
checks: [.statusCheckRollup[]? | {name: (.name // .context), status, conclusion}]}' \
|
|
<<<"$pr_status_json"
|
|
|
|
# Merge gate: all required checks green + at least one maintainer approval.
|
|
# GitHub already folds both into mergeStateStatus:
|
|
# CLEAN — approved, required checks pass, mergeable
|
|
# UNSTABLE — approved, required pass, non-required failing (OK to merge)
|
|
# BLOCKED — missing review, failing required check, or branch-protection block
|
|
# DIRTY — merge conflicts
|
|
# We require reviewDecision=APPROVED too, so a repo without branch protection
|
|
# (which would leave mergeStateStatus=CLEAN even without a review) still blocks.
|
|
ensure_merge_ready() {
|
|
local state review merge_state
|
|
state=$(jq -r '.state' <<<"$pr_status_json")
|
|
review=$(jq -r '.reviewDecision // "NONE"' <<<"$pr_status_json")
|
|
merge_state=$(jq -r '.mergeStateStatus' <<<"$pr_status_json")
|
|
|
|
local ok=1
|
|
if [ "$state" != "OPEN" ]; then
|
|
echo "[review] ! PR state is $state (expected OPEN)" >&2
|
|
ok=0
|
|
fi
|
|
case "$review" in
|
|
APPROVED) ;;
|
|
*)
|
|
echo "[review] ! reviewDecision is $review (expected APPROVED — need a maintainer approval)" >&2
|
|
ok=0
|
|
;;
|
|
esac
|
|
case "$merge_state" in
|
|
CLEAN|UNSTABLE|HAS_HOOKS) ;;
|
|
*)
|
|
echo "[review] ! mergeStateStatus is $merge_state (expected CLEAN/UNSTABLE — required checks may still be pending or failing)" >&2
|
|
ok=0
|
|
;;
|
|
esac
|
|
|
|
# Enumerate any required-looking checks that aren't SUCCESS/NEUTRAL/SKIPPED
|
|
# for a clearer error. mergeStateStatus already covers this; this is just UX.
|
|
local bad_checks
|
|
bad_checks=$(jq -r '
|
|
.statusCheckRollup[]?
|
|
| select(
|
|
(.conclusion // "") as $c
|
|
| (.status // "") as $s
|
|
| ($c | IN("SUCCESS","NEUTRAL","SKIPPED","")) as $okConc
|
|
| ($s | IN("COMPLETED","")) as $okStatus
|
|
| (($okConc and $okStatus) | not)
|
|
)
|
|
| " - \((.name // .context)): status=\(.status // "?"), conclusion=\(.conclusion // "?")"
|
|
' <<<"$pr_status_json")
|
|
if [ -n "$bad_checks" ]; then
|
|
echo "[review] ! checks not green:" >&2
|
|
printf '%s\n' "$bad_checks" >&2
|
|
ok=0
|
|
fi
|
|
|
|
if [ "$ok" != "1" ]; then
|
|
if [ "$force" = "1" ]; then
|
|
echo "[review] --force: proceeding despite merge gate failures." >&2
|
|
return 0
|
|
fi
|
|
echo "[review] refusing to merge. Re-run with --force to override." >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# Substring patterns (case-insensitive) matched against co-author name OR email.
|
|
# Override via REVIEW_BANNED_COAUTHOR_RE env var.
|
|
BANNED_RE="${REVIEW_BANNED_COAUTHOR_RE:-copilot|codex|cursor|claude|anthropic|openai|chatgpt|\[bot\]|noreply@github|users\.noreply\.github\.com}"
|
|
|
|
build_squash_body() {
|
|
local pr="$1" repo="$2" summary_llm="$3" closing_issues="${4:-}"
|
|
local data body title me_name me_email
|
|
data=$(gh pr view "$pr" -R "$repo" --json title,body,commits)
|
|
title=$(jq -r '.title' <<<"$data")
|
|
body=$(jq -r '.body // ""' <<<"$data")
|
|
|
|
me_name=$(git config --get user.name || true)
|
|
me_email=$(git config --get user.email || true)
|
|
if [ -z "$me_name" ] || [ -z "$me_email" ]; then
|
|
echo "[review] git config user.name/user.email not set; cannot add self as co-author" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Strip any existing Co-authored-by trailers from the PR body.
|
|
local body_clean
|
|
body_clean=$(printf '%s\n' "$body" | grep -viE '^co-authored-by:' || true)
|
|
# Trim trailing blank lines.
|
|
body_clean=$(printf '%s\n' "$body_clean" | awk 'NF {p=1} p {lines[NR]=$0; last=NR} END {for (i=1;i<=last;i++) print lines[i]}')
|
|
|
|
# Build input for the summary LLM: title + PR body + commit list.
|
|
local summary_input
|
|
summary_input=$(jq -r '
|
|
"Title: " + .title + "\n\n" +
|
|
"PR body:\n" + (.body // "(empty)") + "\n\n" +
|
|
"Commits:\n" +
|
|
((.commits // [])
|
|
| map("- " + .messageHeadline
|
|
+ (if (.messageBody // "") != ""
|
|
then "\n " + ((.messageBody) | gsub("\n"; "\n "))
|
|
else "" end))
|
|
| join("\n"))
|
|
' <<<"$data")
|
|
|
|
local summary_body
|
|
if [ "$summary_llm" = "none" ] || [ "$summary_llm" = "raw" ]; then
|
|
summary_body="$body_clean"
|
|
else
|
|
echo "[review] summarizing with ${summary_llm}..." >&2
|
|
summary_body=$(summarize_text "$summary_llm" "$summary_input")
|
|
if [ -z "$summary_body" ]; then
|
|
echo "[review] ! summary LLM returned empty output; falling back to PR body" >&2
|
|
summary_body="$body_clean"
|
|
fi
|
|
fi
|
|
|
|
# Collect co-authors from commit authors + Co-authored-by trailers, then
|
|
# filter. tolower()-based match is portable (BSD awk has no IGNORECASE).
|
|
local coauthors
|
|
coauthors=$(jq -r '
|
|
.commits[]
|
|
| (
|
|
(.authors[]? | "\(.name // "")\t\(.email // "")"),
|
|
(.messageBody // "" | split("\n")[]
|
|
| select(test("^[Cc]o-authored-by:"))
|
|
| sub("^[Cc]o-authored-by:\\s*"; "")
|
|
| capture("^(?<n>.+?)\\s*<(?<e>[^>]+)>\\s*$")?
|
|
| "\(.n)\t\(.e)"
|
|
)
|
|
)
|
|
' <<<"$data" \
|
|
| awk -F'\t' -v me="$me_email" -v banned="$BANNED_RE" '
|
|
NF < 2 { next }
|
|
$1 == "" || $2 == "" { next }
|
|
tolower($2) == tolower(me) { next }
|
|
{
|
|
nl = tolower($1); el = tolower($2);
|
|
if (nl ~ banned || el ~ banned) next;
|
|
key = el;
|
|
if (!(key in seen)) {
|
|
seen[key] = 1
|
|
printf "Co-authored-by: %s <%s>\n", $1, $2
|
|
}
|
|
}
|
|
')
|
|
|
|
# Strip any stray closing-keyword lines the LLM or PR body may have
|
|
# emitted — we'll append a canonical block below so GitHub sees one
|
|
# `Closes #N` per linked issue (its regex only matches one ref per keyword,
|
|
# so `Closes #1, #2` would only close #1).
|
|
local summary_clean
|
|
summary_clean=$(printf '%s\n' "$summary_body" \
|
|
| grep -viE '^[[:space:]]*(close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]]+(#|[A-Za-z0-9._-]+/[A-Za-z0-9._-]+#)[0-9]+' \
|
|
|| true)
|
|
|
|
local closes_block=""
|
|
if [ -n "$closing_issues" ]; then
|
|
local n
|
|
for n in $closing_issues; do
|
|
closes_block+="Closes #${n}"$'\n'
|
|
done
|
|
fi
|
|
|
|
{
|
|
if [ -n "$summary_clean" ]; then
|
|
printf '%s\n\n' "$summary_clean"
|
|
fi
|
|
if [ -n "$closes_block" ]; then
|
|
printf '%s\n' "$closes_block"
|
|
fi
|
|
if [ -n "$coauthors" ]; then
|
|
printf '%s\n' "$coauthors"
|
|
fi
|
|
printf 'Co-authored-by: %s <%s>\n' "$me_name" "$me_email"
|
|
}
|
|
: "$title" # reserved for future subject overrides
|
|
}
|
|
|
|
# Gate the merge first — do this BEFORE any LLM summarization so we
|
|
# don't burn tokens on PRs that can't actually be merged. --dry-run is
|
|
# the one case where we still want to print the squash preview regardless.
|
|
extra_flags=()
|
|
if [ "$admin" = "1" ]; then
|
|
echo "[review] --admin: bypassing local gate and using branch-protection override"
|
|
extra_flags+=(--admin)
|
|
elif [ "$auto" = "1" ]; then
|
|
echo "[review] --auto: queueing merge once checks/approvals are satisfied"
|
|
extra_flags+=(--auto)
|
|
elif [ "$dry_run" != "1" ]; then
|
|
ensure_merge_ready
|
|
fi
|
|
|
|
if [ "$strategy" = "--squash" ]; then
|
|
title=$(gh pr view "$pr" -R "$repo" --json title -q .title)
|
|
|
|
# Append any linked "Closes #N" issues that aren't already referenced in the
|
|
# title (skip issue numbers already mentioned as #N).
|
|
closing=$(gh pr view "$pr" -R "$repo" \
|
|
--json closingIssuesReferences \
|
|
--jq '.closingIssuesReferences[].number' 2>/dev/null || true)
|
|
missing=()
|
|
for n in $closing; do
|
|
if ! grep -qE "#${n}([^0-9]|$)" <<<"$title"; then
|
|
missing+=("#${n}")
|
|
fi
|
|
done
|
|
if [ ${#missing[@]} -gt 0 ]; then
|
|
joined=$(printf ', %s' "${missing[@]}")
|
|
joined=${joined:2}
|
|
title="${title} (closes ${joined})"
|
|
fi
|
|
|
|
body=$(build_squash_body "$pr" "$repo" "$summary_llm" "$closing")
|
|
echo "[review] squash commit message:"
|
|
printf -- '----\n%s (#%s)\n\n%s\n----\n' "$title" "$pr" "$body"
|
|
if [ "$dry_run" = "1" ]; then
|
|
echo "[review] --dry-run: not merging."
|
|
exit 0
|
|
fi
|
|
echo "[review] merging PR #$pr with --squash..."
|
|
gh pr merge "$pr" -R "$repo" --squash --delete-branch \
|
|
--subject "$title (#$pr)" \
|
|
--body "$body" \
|
|
${extra_flags[@]+"${extra_flags[@]}"}
|
|
else
|
|
if [ "$dry_run" = "1" ]; then
|
|
echo "[review] --dry-run: $strategy does not rewrite the commit message; nothing to preview."
|
|
exit 0
|
|
fi
|
|
echo "[review] merging PR #$pr with $strategy..."
|
|
gh pr merge "$pr" -R "$repo" "$strategy" --delete-branch ${extra_flags[@]+"${extra_flags[@]}"}
|
|
fi
|
|
echo "[review] merged."
|