fleet-agents 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/review/lib.sh ADDED
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env bash
2
+ # Shared helpers for scripts/shortcuts/review/*.sh
3
+ # Source this file; do not execute directly.
4
+
5
+ set -euo pipefail
6
+
7
+ # Repo that hosts the PR. Override with REVIEW_REPO=owner/name if needed;
8
+ # otherwise we derive it from the `upstream` remote, falling back to `origin`.
9
+ resolve_repo() {
10
+ if [ -n "${REVIEW_REPO:-}" ]; then
11
+ echo "$REVIEW_REPO"
12
+ return
13
+ fi
14
+ local url
15
+ url=$(git remote get-url upstream 2>/dev/null || git remote get-url origin)
16
+ # Accept git@github.com:owner/name(.git) and https://github.com/owner/name(.git)
17
+ echo "$url" \
18
+ | sed -E 's#^git@github\.com:##; s#^https?://github\.com/##; s#\.git$##'
19
+ }
20
+
21
+ require() {
22
+ local bin
23
+ for bin in "$@"; do
24
+ command -v "$bin" >/dev/null 2>&1 || {
25
+ echo "[review] missing required tool: $bin" >&2
26
+ exit 1
27
+ }
28
+ done
29
+ }
30
+
31
+ # Run the picked agent CLI on a single positional prompt. Each known agent
32
+ # is launched in its equivalent "yolo" mode so headless / detached runs
33
+ # (CI, background tasks, tmux workers) don't stall on per-tool permission
34
+ # prompts that have no responder. Set REVIEW_AGENT_SAFE=1 to keep the
35
+ # prompts (e.g. an interactive local run where you want to vet each step).
36
+ #
37
+ # Mirrors the precedent in bin/spawn-issue, which already passes
38
+ # --dangerously-skip-permissions to its detached claude workers, and brings
39
+ # the claude path in line with the existing codex / cursor handling.
40
+ agent_exec() {
41
+ local agent="$1"
42
+ local prompt="$2"
43
+ if [ "${REVIEW_AGENT_SAFE:-0}" = "1" ]; then
44
+ case "$agent" in
45
+ codex) exec codex "$prompt" ;;
46
+ claude) exec claude "$prompt" ;;
47
+ *) exec "$agent" "$prompt" ;;
48
+ esac
49
+ return
50
+ fi
51
+ case "$agent" in
52
+ claude)
53
+ exec claude --dangerously-skip-permissions "$prompt"
54
+ ;;
55
+ codex)
56
+ exec codex --dangerously-bypass-approvals-and-sandbox "$prompt"
57
+ ;;
58
+ cursor|cursor-agent)
59
+ exec cursor-agent --yolo "$prompt"
60
+ ;;
61
+ *)
62
+ exec "$agent" "$prompt"
63
+ ;;
64
+ esac
65
+ }
66
+
67
+ gh_assign_self_issue() {
68
+ local issue="$1"
69
+ local repo="$2"
70
+ if gh issue edit "$issue" -R "$repo" --add-assignee "@me" >/dev/null 2>&1; then
71
+ info "assigned issue #$issue to @me"
72
+ else
73
+ warn "could not assign issue #$issue to @me; continuing"
74
+ fi
75
+ }
76
+
77
+ gh_assign_self_pr() {
78
+ local pr="$1"
79
+ local repo="$2"
80
+ if gh pr edit "$pr" -R "$repo" --add-assignee "@me" >/dev/null 2>&1; then
81
+ info "assigned PR #$pr to @me"
82
+ else
83
+ warn "could not assign PR #$pr to @me; continuing"
84
+ fi
85
+ }
86
+
87
+ # Summarize free-form text via a local LLM CLI (expects `-p <prompt>`).
88
+ # Usage: summarize_text <tool> <input>
89
+ # Tools used here: gemini (default for summaries), claude, or any CLI that
90
+ # accepts `-p "<prompt>"` and prints the response to stdout.
91
+ # Special value `none` echoes input unchanged.
92
+ summarize_text() {
93
+ local tool="$1"
94
+ local input="$2"
95
+ if [ "$tool" = "none" ] || [ "$tool" = "raw" ]; then
96
+ printf '%s' "$input"
97
+ return
98
+ fi
99
+ require "$tool"
100
+ local prompt
101
+ prompt=$(cat <<'EOF'
102
+ You are writing the body of a squash-merge commit.
103
+ Summarize the PR changes below into 3-6 short bullet points.
104
+ Rules:
105
+ - Start each bullet with "- " and use imperative mood ("Add…", "Fix…", "Rename…").
106
+ - One line per bullet, under ~100 chars.
107
+ - No headers, no code fences, no sign-offs, no Co-authored-by lines.
108
+ - Do not include the PR number or title.
109
+ - Output only the bullets, nothing else.
110
+
111
+ PR content:
112
+ ---
113
+ EOF
114
+ )
115
+ "$tool" -p "${prompt}
116
+ ${input}
117
+ ---"
118
+ }
119
+
120
+ require_pr_number() {
121
+ if [ -z "${1:-}" ]; then
122
+ echo "Usage: $(basename "$0") <pr-number>" >&2
123
+ exit 1
124
+ fi
125
+ case "$1" in
126
+ ''|*[!0-9]*)
127
+ echo "[review] pr-number must be numeric, got: $1" >&2
128
+ exit 1
129
+ ;;
130
+ esac
131
+ }
132
+
133
+ # ── Coloured output ────────────────────────────────────────────────
134
+ # Disable colour when stdout is not a terminal (piped / CI).
135
+ if [ -t 1 ]; then
136
+ _R=$'\033[0;31m' _G=$'\033[0;32m' _Y=$'\033[0;33m' _B=$'\033[1m' _0=$'\033[0m'
137
+ else
138
+ _R="" _G="" _Y="" _B="" _0=""
139
+ fi
140
+
141
+ pass() { printf '%s[PASS]%s %s\n' "$_G" "$_0" "$*"; }
142
+ fail() { printf '%s[FAIL]%s %s\n' "$_R" "$_0" "$*" >&2; }
143
+ warn() { printf '%s[WARN]%s %s\n' "$_Y" "$_0" "$*" >&2; }
144
+ info() { printf '%s[INFO]%s %s\n' "$_B" "$_0" "$*"; }
145
+
146
+ # Fetch PR head into local branch pr/<num>, merge main in, wire upstream +
147
+ # pushRemote so `git push` lands on the contributor's fork.
148
+ sync_pr() {
149
+ local pr="$1"
150
+ local repo
151
+ repo=$(resolve_repo)
152
+
153
+ local info head_repo head_branch local_branch base_branch
154
+ info=$(gh pr view "$pr" -R "$repo" \
155
+ --json headRefName,headRepository,headRepositoryOwner,baseRefName)
156
+ head_repo=$(echo "$info" | jq -r '.headRepositoryOwner.login + "/" + .headRepository.name')
157
+ head_branch=$(echo "$info" | jq -r '.headRefName')
158
+ # The PR's actual base branch (e.g. develop) is authoritative; don't assume main.
159
+ base_branch=$(echo "$info" | jq -r '.baseRefName')
160
+ [ -z "$base_branch" ] || [ "$base_branch" = "null" ] && base_branch="main"
161
+ local_branch="pr/$pr"
162
+
163
+ echo "[review] PR #$pr -> $head_repo:$head_branch (base: $base_branch, local: $local_branch)"
164
+
165
+ # Fetch the PR head into local branch pr/<n> (refs are shared across worktrees).
166
+ git fetch origin "$base_branch"
167
+ # On a reused review worktree, pr/<n> is already checked out, and fetching
168
+ # directly into a checked-out branch is fatal. Only seed the branch on first
169
+ # creation; reuse refreshes it via the push remote further down.
170
+ if git rev-parse --verify --quiet "refs/heads/${local_branch}" >/dev/null; then
171
+ echo "[review] $local_branch already exists — skipping direct head fetch (refreshed via push remote below)"
172
+ else
173
+ git fetch "https://github.com/${head_repo}.git" \
174
+ "+${head_branch}:${local_branch}"
175
+ fi
176
+
177
+ local merge_ref="$base_branch"
178
+ if [ "${REVIEW_WORKTREE:-0}" = "1" ]; then
179
+ # Isolated review worktree: never touch the primary checkout's branch.
180
+ local wt="${REVIEW_WT_DIR:?[review] REVIEW_WT_DIR required in worktree mode}"
181
+ if [ -d "$wt" ]; then
182
+ echo "[review] reusing review worktree: $wt"
183
+ cd "$wt"
184
+ git checkout "$local_branch"
185
+ else
186
+ echo "[review] creating review worktree: $wt (branch $local_branch)"
187
+ mkdir -p "$(dirname "$wt")"
188
+ git worktree add --force "$wt" "$local_branch"
189
+ cd "$wt"
190
+ fi
191
+ # Bring the base branch in via a ref (it may be checked out elsewhere).
192
+ merge_ref="origin/$base_branch"
193
+ if git remote get-url upstream >/dev/null 2>&1; then
194
+ git fetch upstream
195
+ git rev-parse --verify "upstream/$base_branch" >/dev/null 2>&1 && merge_ref="upstream/$base_branch"
196
+ fi
197
+ else
198
+ echo "[review] syncing $base_branch from upstream..."
199
+ git checkout "$base_branch"
200
+ git pull origin "$base_branch"
201
+ git fetch upstream
202
+ git merge "upstream/$base_branch"
203
+ git checkout "$local_branch"
204
+ fi
205
+ # A stray gitlink without a matching .gitmodules entry (e.g. an accidentally
206
+ # committed .claude/worktrees/* path) makes this fatal under `set -e` and would
207
+ # abort the whole review. Submodules aren't needed to review a diff, so warn and
208
+ # continue rather than die.
209
+ git submodule update --init --recursive \
210
+ || warn "submodule update failed (continuing) — likely a stale gitlink with no .gitmodules entry"
211
+
212
+ echo "[review] merging $merge_ref into $local_branch (conflicts will not abort)..."
213
+ REVIEW_HAS_CONFLICTS=0
214
+ REVIEW_CONFLICT_FILES=""
215
+ if ! git merge --no-edit "$merge_ref"; then
216
+ REVIEW_CONFLICT_FILES=$(git diff --name-only --diff-filter=U | sort -u)
217
+ if [ -z "$REVIEW_CONFLICT_FILES" ]; then
218
+ fail "git merge $merge_ref failed for a non-conflict reason"
219
+ return 1
220
+ fi
221
+ echo "[review] ! conflicts detected in PR #$pr, continuing."
222
+ REVIEW_HAS_CONFLICTS=1
223
+ fi
224
+
225
+ # Prefer an existing SSH remote pointing at this fork to avoid https auth prompts.
226
+ local remote_name="remote-$pr"
227
+ local existing_ssh
228
+ existing_ssh=$(git remote -v \
229
+ | awk -v repo="$head_repo" '$2 ~ ("[:/]" repo "(\\.git)?$") && $3 == "(fetch)" {print $1; exit}')
230
+ if [ -n "$existing_ssh" ]; then
231
+ remote_name="$existing_ssh"
232
+ echo "[review] reusing remote '$remote_name' -> $(git remote get-url "$remote_name")"
233
+ else
234
+ local remote_url="https://github.com/${head_repo}.git"
235
+ git remote add "$remote_name" "$remote_url" 2>/dev/null \
236
+ || git remote set-url "$remote_name" "$remote_url"
237
+ fi
238
+
239
+ git fetch "$remote_name" \
240
+ "+refs/heads/${head_branch}:refs/remotes/${remote_name}/${head_branch}"
241
+
242
+ git branch --set-upstream-to="$remote_name/$head_branch" "$local_branch"
243
+ git config "branch.${local_branch}.pushRemote" "$remote_name"
244
+ git config "branch.${local_branch}.merge" "refs/heads/${head_branch}"
245
+
246
+ echo "[review] upstream + pushRemote set to $remote_name/$head_branch"
247
+
248
+ # Export for callers.
249
+ REVIEW_PR="$pr"
250
+ REVIEW_REPO_RESOLVED="$repo"
251
+ REVIEW_LOCAL_BRANCH="$local_branch"
252
+ REVIEW_HEAD_REPO="$head_repo"
253
+ REVIEW_HEAD_BRANCH="$head_branch"
254
+ REVIEW_PUSH_REMOTE="$remote_name"
255
+ export REVIEW_PR REVIEW_REPO_RESOLVED REVIEW_LOCAL_BRANCH \
256
+ REVIEW_HEAD_REPO REVIEW_HEAD_BRANCH REVIEW_PUSH_REMOTE \
257
+ REVIEW_HAS_CONFLICTS REVIEW_CONFLICT_FILES
258
+ }
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env bash
2
+ # merge.sh <pr-number> [--squash|--merge|--rebase] [--dry-run] [--summary-llm <tool>]
3
+ # Merge a PR via gh. Defaults to --squash.
4
+ #
5
+ # For --squash we rewrite the commit body:
6
+ # - summarize the PR body + commit messages with the summary LLM
7
+ # (default: gemini; use `none` to skip and keep the raw PR body)
8
+ # - drop any Co-authored-by lines mentioning copilot / codex / cursor / claude
9
+ # - add the current `git config user.name <user.email>` as a co-author
10
+ # --merge and --rebase keep the original commits as-is.
11
+ #
12
+ # --dry-run prints the squash subject + body that would be used and exits
13
+ # without calling `gh pr merge`. Ignored for --merge / --rebase.
14
+
15
+ set -euo pipefail
16
+ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17
+ # shellcheck source=lib.sh
18
+ source "$here/lib.sh"
19
+
20
+ require git gh jq
21
+ require_pr_number "${1:-}"
22
+
23
+ pr="$1"
24
+ strategy="--squash"
25
+ dry_run=0
26
+ force=0
27
+ admin=0
28
+ auto=0
29
+ summary_llm="gemini"
30
+ shift
31
+ while [ $# -gt 0 ]; do
32
+ case "$1" in
33
+ --squash|--merge|--rebase) strategy="$1"; shift ;;
34
+ --dry-run|-n) dry_run=1; shift ;;
35
+ --force|-f) force=1; shift ;;
36
+ --admin) admin=1; shift ;;
37
+ --auto) auto=1; shift ;;
38
+ --summary-llm) summary_llm="${2:?--summary-llm requires a value}"; shift 2 ;;
39
+ --summary-llm=*) summary_llm="${1#*=}"; shift ;;
40
+ *)
41
+ echo "[review] unknown arg: $1 (expected --squash|--merge|--rebase|--dry-run|--force|--admin|--auto|--summary-llm)" >&2
42
+ exit 1
43
+ ;;
44
+ esac
45
+ done
46
+
47
+ if [ "$admin" = "1" ] && [ "$auto" = "1" ]; then
48
+ echo "[review] --admin and --auto are mutually exclusive" >&2
49
+ exit 1
50
+ fi
51
+
52
+ repo=$(resolve_repo)
53
+
54
+ echo "[review] PR #$pr status on $repo:"
55
+ pr_status_json=$(gh pr view "$pr" -R "$repo" \
56
+ --json state,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,isDraft,body,reviews)
57
+ jq '{state, mergeable, mergeStateStatus, reviewDecision, isDraft,
58
+ checks: [.statusCheckRollup[]? | {name: (.name // .context), status, conclusion}]}' \
59
+ <<<"$pr_status_json"
60
+
61
+ ensure_merge_ready() {
62
+ local failures=0 total=8
63
+
64
+ # ── Check 1: Not a draft ──
65
+ local is_draft
66
+ is_draft=$(jq -r '.isDraft' <<<"$pr_status_json")
67
+ if [ "$is_draft" = "true" ]; then
68
+ fail "PR is still in draft"
69
+ failures=$((failures + 1))
70
+ else
71
+ pass "PR is not a draft"
72
+ fi
73
+
74
+ # ── Check 2: CI passing ──
75
+ local bad_checks
76
+ bad_checks=$(jq -r '
77
+ .statusCheckRollup[]?
78
+ | select(
79
+ (.conclusion // "") as $c
80
+ | (.status // "") as $s
81
+ | ($c | IN("SUCCESS","NEUTRAL","SKIPPED","")) as $okConc
82
+ | ($s | IN("COMPLETED","")) as $okStatus
83
+ | (($okConc and $okStatus) | not)
84
+ )
85
+ | " \((.name // .context)): status=\(.status // "?"), conclusion=\(.conclusion // "?")"
86
+ ' <<<"$pr_status_json")
87
+ if [ -n "$bad_checks" ]; then
88
+ fail "CI checks not all green:"
89
+ printf '%s\n' "$bad_checks" >&2
90
+ failures=$((failures + 1))
91
+ else
92
+ pass "All CI checks passing"
93
+ fi
94
+
95
+ # ── Check 3: No merge conflicts ──
96
+ local mergeable
97
+ mergeable=$(jq -r '.mergeable' <<<"$pr_status_json")
98
+ case "$mergeable" in
99
+ MERGEABLE) pass "No merge conflicts" ;;
100
+ CONFLICTING)
101
+ fail "PR has merge conflicts"
102
+ failures=$((failures + 1))
103
+ ;;
104
+ *)
105
+ fail "Merge status unknown ($mergeable)"
106
+ failures=$((failures + 1))
107
+ ;;
108
+ esac
109
+
110
+ # ── Check 4: All review threads resolved ──
111
+ local unresolved
112
+ unresolved=$(gh api graphql -f query='
113
+ query($owner:String!,$repo:String!,$pr:Int!) {
114
+ repository(owner:$owner,name:$repo) {
115
+ pullRequest(number:$pr) {
116
+ reviewThreads(first:100) {
117
+ nodes { isResolved isOutdated }
118
+ }
119
+ }
120
+ }
121
+ }' -f owner="${repo%%/*}" -f repo="${repo##*/}" -F pr="$pr" \
122
+ --jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false and .isOutdated == false)] | length')
123
+ if [ "${unresolved:-0}" -gt 0 ]; then
124
+ fail "$unresolved unresolved review thread(s)"
125
+ failures=$((failures + 1))
126
+ else
127
+ pass "All review threads resolved"
128
+ fi
129
+
130
+ # ── Check 5: At least one approval ──
131
+ local review_decision
132
+ review_decision=$(jq -r '.reviewDecision // "NONE"' <<<"$pr_status_json")
133
+ if [ "$review_decision" = "APPROVED" ]; then
134
+ pass "Has at least one approval"
135
+ else
136
+ fail "Review decision is $review_decision (need APPROVED)"
137
+ failures=$((failures + 1))
138
+ fi
139
+
140
+ # ── Check 6: No REQUEST_CHANGES pending ──
141
+ # Get the latest review state per author — a later APPROVED supersedes an earlier REQUEST_CHANGES
142
+ local pending_changes
143
+ pending_changes=$(jq -r '
144
+ [.reviews // [] | sort_by(.submittedAt) | group_by(.author.login)[]
145
+ | last | select(.state == "CHANGES_REQUESTED")]
146
+ | length' <<<"$pr_status_json")
147
+ if [ "${pending_changes:-0}" -gt 0 ]; then
148
+ fail "$pending_changes reviewer(s) still requesting changes"
149
+ failures=$((failures + 1))
150
+ else
151
+ pass "No pending change requests"
152
+ fi
153
+
154
+ # ── Check 7: Coverage gate passed ──
155
+ local cov_status
156
+ cov_status=$(jq -r '
157
+ [.statusCheckRollup[]?
158
+ | select((.name // .context) | test("coverage"; "i"))]
159
+ | if length == 0 then "MISSING"
160
+ elif all(.conclusion == "SUCCESS") then "SUCCESS"
161
+ else (map(select(.conclusion != "SUCCESS"))[0].conclusion // "UNKNOWN")
162
+ end' <<<"$pr_status_json")
163
+ case "$cov_status" in
164
+ SUCCESS) pass "Coverage gate passed" ;;
165
+ MISSING)
166
+ warn "Coverage check not found in status checks"
167
+ fail "Coverage gate status unknown"
168
+ failures=$((failures + 1))
169
+ ;;
170
+ *)
171
+ fail "Coverage gate: $cov_status"
172
+ failures=$((failures + 1))
173
+ ;;
174
+ esac
175
+
176
+ # ── Check 8: PR description has required sections ──
177
+ local body
178
+ body=$(jq -r '.body // ""' <<<"$pr_status_json")
179
+ local missing_sections=()
180
+ for section in "Summary" "Problem" "Solution"; do
181
+ if ! grep -qiE "^##[[:space:]]+${section}" <<<"$body"; then
182
+ missing_sections+=("$section")
183
+ fi
184
+ done
185
+ if [ ${#missing_sections[@]} -gt 0 ]; then
186
+ fail "PR description missing sections: ${missing_sections[*]}"
187
+ failures=$((failures + 1))
188
+ else
189
+ pass "PR description has required sections"
190
+ fi
191
+
192
+ # ── Summary ──
193
+ local passed=$((total - failures))
194
+ echo ""
195
+ if [ "$failures" -gt 0 ]; then
196
+ info "${_B}${passed}/${total} checks passed${_0}"
197
+ if [ "$force" = "1" ]; then
198
+ warn "--force: proceeding despite $failures failure(s)."
199
+ return 0
200
+ fi
201
+ fail "Refusing to merge. Re-run with --force to override."
202
+ exit 1
203
+ else
204
+ info "${_G}${total}/${total} checks passed${_0} — ready to merge"
205
+ fi
206
+ }
207
+
208
+ # Substring patterns (case-insensitive) matched against co-author name OR email.
209
+ # Override via REVIEW_BANNED_COAUTHOR_RE env var.
210
+ BANNED_RE="${REVIEW_BANNED_COAUTHOR_RE:-copilot|codex|cursor|claude|anthropic|openai|chatgpt|\[bot\]|noreply@github|users\.noreply\.github\.com}"
211
+
212
+ build_squash_body() {
213
+ local pr="$1" repo="$2" summary_llm="$3" closing_issues="${4:-}"
214
+ local data body title me_name me_email
215
+ data=$(gh pr view "$pr" -R "$repo" --json title,body,commits)
216
+ title=$(jq -r '.title' <<<"$data")
217
+ body=$(jq -r '.body // ""' <<<"$data")
218
+
219
+ me_name=$(git config --get user.name || true)
220
+ me_email=$(git config --get user.email || true)
221
+ if [ -z "$me_name" ] || [ -z "$me_email" ]; then
222
+ echo "[review] git config user.name/user.email not set; cannot add self as co-author" >&2
223
+ exit 1
224
+ fi
225
+
226
+ # Strip any existing Co-authored-by trailers from the PR body.
227
+ local body_clean
228
+ body_clean=$(printf '%s\n' "$body" | grep -viE '^co-authored-by:' || true)
229
+ # Trim trailing blank lines.
230
+ 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]}')
231
+
232
+ # Build input for the summary LLM: title + PR body + commit list.
233
+ local summary_input
234
+ summary_input=$(jq -r '
235
+ "Title: " + .title + "\n\n" +
236
+ "PR body:\n" + (.body // "(empty)") + "\n\n" +
237
+ "Commits:\n" +
238
+ ((.commits // [])
239
+ | map("- " + .messageHeadline
240
+ + (if (.messageBody // "") != ""
241
+ then "\n " + ((.messageBody) | gsub("\n"; "\n "))
242
+ else "" end))
243
+ | join("\n"))
244
+ ' <<<"$data")
245
+
246
+ local summary_body
247
+ if [ "$summary_llm" = "none" ] || [ "$summary_llm" = "raw" ]; then
248
+ summary_body="$body_clean"
249
+ else
250
+ echo "[review] summarizing with ${summary_llm}..." >&2
251
+ summary_body=$(summarize_text "$summary_llm" "$summary_input")
252
+ if [ -z "$summary_body" ]; then
253
+ echo "[review] ! summary LLM returned empty output; falling back to PR body" >&2
254
+ summary_body="$body_clean"
255
+ fi
256
+ fi
257
+
258
+ # Collect co-authors from commit authors + Co-authored-by trailers, then
259
+ # filter. tolower()-based match is portable (BSD awk has no IGNORECASE).
260
+ local coauthors
261
+ coauthors=$(jq -r '
262
+ .commits[]
263
+ | (
264
+ (.authors[]? | "\(.name // "")\t\(.email // "")"),
265
+ (.messageBody // "" | split("\n")[]
266
+ | select(test("^[Cc]o-authored-by:"))
267
+ | sub("^[Cc]o-authored-by:\\s*"; "")
268
+ | capture("^(?<n>.+?)\\s*<(?<e>[^>]+)>\\s*$")?
269
+ | "\(.n)\t\(.e)"
270
+ )
271
+ )
272
+ ' <<<"$data" \
273
+ | awk -F'\t' -v me="$me_email" -v banned="$BANNED_RE" '
274
+ NF < 2 { next }
275
+ $1 == "" || $2 == "" { next }
276
+ tolower($2) == tolower(me) { next }
277
+ {
278
+ nl = tolower($1); el = tolower($2);
279
+ if (nl ~ banned || el ~ banned) next;
280
+ key = el;
281
+ if (!(key in seen)) {
282
+ seen[key] = 1
283
+ printf "Co-authored-by: %s <%s>\n", $1, $2
284
+ }
285
+ }
286
+ ')
287
+
288
+ # Strip any stray closing-keyword lines the LLM or PR body may have
289
+ # emitted — we'll append a canonical block below so GitHub sees one
290
+ # `Closes #N` per linked issue (its regex only matches one ref per keyword,
291
+ # so `Closes #1, #2` would only close #1).
292
+ local summary_clean
293
+ summary_clean=$(printf '%s\n' "$summary_body" \
294
+ | grep -viE '^[[:space:]]*(close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]]+(#|[A-Za-z0-9._-]+/[A-Za-z0-9._-]+#)[0-9]+' \
295
+ || true)
296
+
297
+ local closes_block=""
298
+ if [ -n "$closing_issues" ]; then
299
+ local n
300
+ for n in $closing_issues; do
301
+ closes_block+="Closes #${n}"$'\n'
302
+ done
303
+ fi
304
+
305
+ {
306
+ if [ -n "$summary_clean" ]; then
307
+ printf '%s\n\n' "$summary_clean"
308
+ fi
309
+ if [ -n "$closes_block" ]; then
310
+ printf '%s\n' "$closes_block"
311
+ fi
312
+ if [ -n "$coauthors" ]; then
313
+ printf '%s\n' "$coauthors"
314
+ fi
315
+ printf 'Co-authored-by: %s <%s>\n' "$me_name" "$me_email"
316
+ }
317
+ : "$title" # reserved for future subject overrides
318
+ }
319
+
320
+ # Gate the merge first — do this BEFORE any LLM summarization so we
321
+ # don't burn tokens on PRs that can't actually be merged. --dry-run is
322
+ # the one case where we still want to print the squash preview regardless.
323
+ extra_flags=()
324
+ if [ "$admin" = "1" ]; then
325
+ echo "[review] --admin: bypassing local gate and using branch-protection override"
326
+ extra_flags+=(--admin)
327
+ elif [ "$auto" = "1" ]; then
328
+ echo "[review] --auto: queueing merge once checks/approvals are satisfied"
329
+ extra_flags+=(--auto)
330
+ elif [ "$dry_run" != "1" ]; then
331
+ ensure_merge_ready
332
+ fi
333
+
334
+ if [ "$strategy" = "--squash" ]; then
335
+ title=$(gh pr view "$pr" -R "$repo" --json title -q .title)
336
+
337
+ # Append any linked "Closes #N" issues that aren't already referenced in the
338
+ # title (skip issue numbers already mentioned as #N).
339
+ closing=$(gh pr view "$pr" -R "$repo" \
340
+ --json closingIssuesReferences \
341
+ --jq '.closingIssuesReferences[].number' 2>/dev/null || true)
342
+ missing=()
343
+ for n in $closing; do
344
+ if ! grep -qE "#${n}([^0-9]|$)" <<<"$title"; then
345
+ missing+=("#${n}")
346
+ fi
347
+ done
348
+ if [ ${#missing[@]} -gt 0 ]; then
349
+ joined=$(printf ', %s' "${missing[@]}")
350
+ joined=${joined:2}
351
+ title="${title} (closes ${joined})"
352
+ fi
353
+
354
+ body=$(build_squash_body "$pr" "$repo" "$summary_llm" "$closing")
355
+ echo "[review] squash commit message:"
356
+ printf -- '----\n%s (#%s)\n\n%s\n----\n' "$title" "$pr" "$body"
357
+ if [ "$dry_run" = "1" ]; then
358
+ echo "[review] --dry-run: not merging."
359
+ exit 0
360
+ fi
361
+ echo "[review] merging PR #$pr with --squash..."
362
+ gh pr merge "$pr" -R "$repo" --squash --delete-branch \
363
+ --subject "$title (#$pr)" \
364
+ --body "$body" \
365
+ ${extra_flags[@]+"${extra_flags[@]}"}
366
+ else
367
+ if [ "$dry_run" = "1" ]; then
368
+ echo "[review] --dry-run: $strategy does not rewrite the commit message; nothing to preview."
369
+ exit 0
370
+ fi
371
+ echo "[review] merging PR #$pr with $strategy..."
372
+ gh pr merge "$pr" -R "$repo" "$strategy" --delete-branch ${extra_flags[@]+"${extra_flags[@]}"}
373
+ fi
374
+ echo "[review] merged."