#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2025-2026 Marcus Quinn
# pulse-merge.sh — Co-extracted pr-gates + merge clusters (2-cycle) — PR gate checks (external contributor, permission, workflow scope) and merge-ready PR processing + linked-issue extraction.
#
# Extracted from pulse-wrapper.sh in Phase 4 of the phased decomposition
# (parent: GH#18356, plan: todo/plans/pulse-wrapper-decomposition.md §6).
#
# NOTE: This module contains TWO clusters (pr-gates + merge) that form a
# dependency cycle. They must be co-located in the same module so bash's
# lazy function name resolution can see both sides of the cycle after a
# single source. Splitting them would require defining stubs or ordering
# sources against call graphs — the plan chose co-extraction as simpler.
#
# GH#21301: further split — PR gate functions into pulse-merge-gates.sh,
# merge processing helpers into pulse-merge-process.sh. Functions over
# 100 lines stay here to preserve (file, fname) identity keys for the
# complexity scanners. Bash's lazy function resolution handles all
# cross-module calls at invocation time.
#
# In GH#19836 the module was further trimmed by extracting two downstream
# clusters that are called after the gate checks fire. They are sourced
# by pulse-wrapper.sh AFTER pulse-merge.sh so they can use shared merge
# helpers such as _extract_linked_issue, while Bash lazy resolution keeps
# the runtime cross-module calls safe. The dependency is one-way only
# (downstream → core); merge-core/pr-gates do not require the downstream
# modules at source time:
#   - pulse-merge-conflict.sh — conflict handling, interactive handover,
#     carry-forward diff, rebase nudges
#   - pulse-merge-feedback.sh — CI/conflict/review feedback routing to
#     linked issues with PR close
# Example cross-module call: _check_pr_merge_gates (merge-core) →
# _dispatch_pr_fix_worker (feedback) is resolved at invocation time.
#
# This module is sourced by pulse-wrapper.sh. It MUST NOT be executed
# directly — it relies on the orchestrator having sourced:
#   shared-constants.sh
#   worker-lifecycle-common.sh
# and having defined all PULSE_* configuration constants in the bootstrap
# section.
#
# Functions in this module (in source order):
#   - _pm_issue_api                          (module-level helper)
#   Functions delegated to sub-libraries:
#   - pulse-merge-gates.sh: check_external_contributor_pr, _external_pr_has_linked_issue,
#     _external_pr_linked_issue_crypto_approved, _pulse_merge_admin_safety_check,
#     check_permission_failure_pr, approve_collaborator_pr, check_pr_modifies_workflows,
#     check_gh_workflow_scope, check_workflow_merge_guard
#   - pulse-merge-process.sh: merge_ready_prs_all_repos, _merge_ready_prs_for_repo,
#     _attempt_pr_update_branch, _resolve_pr_mergeable_status,
#     _pulse_merge_dismiss_coderabbit_nits, _pr_required_checks_pass,
#     _attempt_pr_ci_rebase_retry, _route_pr_to_fix_worker,
#     _retarget_stacked_children, _attempt_worker_briefed_auto_merge,
#     _attempt_green_behind_update_branch, _check_required_checks_passing
#   - pulse-merge-author-checks.sh: _is_collaborator_author,
#     _is_owner_or_member_author, _check_interactive_pr_gates
#   Functions kept here (>100 lines — identity-key preservation):
#   - _check_pr_merge_gates                  (166 lines)
#   - _handle_post_merge_actions             (107 lines)
#   - _process_single_ready_pr               (211 lines)
#   Extraction utilities (used by downstream modules):
#   - _extract_linked_issue
#   - _extract_merge_summary
#
# This was originally a pure move from pulse-wrapper.sh. Later additions
# (rebase nudges GH#18650/GH#18815, review-feedback routing t2093, the
# GH#19836 split, GH#21301 sub-library split) preserve that call site.

# Include guard — prevent double-sourcing.
[[ -n "${_PULSE_MERGE_LOADED:-}" ]] && return 0
_PULSE_MERGE_LOADED=1

# t2863: Module-level variable defaults (set -u guards).
# When this module is sourced standalone (e.g. pulse-merge-routine.sh, test
# harnesses), the pulse-wrapper.sh bootstrap has NOT run. Guard each bare var
# used across this module's functions so set -u does not abort them.
# The :=default form sets the var only when unset or empty; pre-existing values
# from the orchestrator bootstrap are preserved.
: "${LOGFILE:=${HOME}/.aidevops/logs/pulse.log}"
: "${STOP_FLAG:=${HOME}/.aidevops/logs/pulse-session.stop}"
: "${PULSE_MERGE_BATCH_LIMIT:=50}"
: "${PULSE_MERGE_CLOSE_CONFLICTING:=true}"

# Comma-delimited label pattern constant — avoids matching "origin:worker-takeover"
# when checking for "origin:worker" in comma-joined label strings. (t2449)
_OW_LABEL_PAT=",origin:worker,"

# Build issue API path from repo slug and issue number. Module-level helper
# avoids repeating the path literal across multiple function scopes.
_pm_issue_api() {
	local slug="$1"
	local issue_num="$2"
	printf 'repos/%s/issues/%s' "$slug" "$issue_num"
	return 0
}

_pulse_merge_repo_path_for_slug() {
	local repo_slug="$1"
	local repos_json="${AIDEVOPS_REPOS_JSON:-${HOME}/.config/aidevops/repos.json}"
	local repo_path=""
	[[ -n "$repo_slug" && -f "$repos_json" ]] || return 1
	repo_path=$(jq -r --arg slug "$repo_slug" '
		.initialized_repos[]? | select(.slug == $slug) | .path // empty
	' "$repos_json" 2>/dev/null | sed -n '1p') || repo_path=""
	[[ -n "$repo_path" ]] || return 1
	printf '%s\n' "${repo_path/#\~/$HOME}"
	return 0
}

_pulse_merge_dispatch_review_thread_remediation() {
	local pr_number="$1"
	local repo_slug="$2"
	local reason="$3"
	local repo_path="" scanner="${_PULSE_MERGE_DIR:-}/pr-review-thread-response-scanner.sh"

	if [[ ! -x "$scanner" ]]; then
		echo "[pulse-merge] review-thread remediation skipped for PR #${pr_number} in ${repo_slug}: scanner missing or not executable (${scanner})" >>"$LOGFILE"
		return 1
	fi
	if ! repo_path="$(_pulse_merge_repo_path_for_slug "$repo_slug")"; then
		echo "[pulse-merge] review-thread remediation skipped for PR #${pr_number} in ${repo_slug}: repo path not found in configured repos" >>"$LOGFILE"
		return 1
	fi
	if ! PR_REVIEW_THREAD_RESPONSE_INCLUDE_HUMAN=true "$scanner" dispatch-pr "$repo_slug" "$repo_path" "$pr_number" >>"$LOGFILE" 2>&1; then
		echo "[pulse-merge] review-thread remediation dispatch failed for PR #${pr_number} in ${repo_slug} ${reason}" >>"$LOGFILE"
		return 1
	fi
	echo "[pulse-merge] review-thread remediation queued for PR #${pr_number} in ${repo_slug} ${reason}" >>"$LOGFILE"
	return 0
}

_pulse_merge_maybe_dispatch_review_thread_remediation() {
	local pr_number="$1"
	local repo_slug="$2"
	local merge_output="$3"

	[[ "$merge_output" == *"A conversation must be resolved"* ]] || return 0
	_pulse_merge_dispatch_review_thread_remediation "$pr_number" "$repo_slug" "after unresolved conversation merge blocker" || true
	return 0
}

_pulse_merge_changes_requested_thread_remediation_first_enabled() {
	[[ "${AIDEVOPS_CHANGES_REQUESTED_THREAD_REMEDIATION_FIRST:-0}" == "1" ]] || return 1
	return 0
}

# Standard PR JSON fields consumed by _process_single_ready_pr. Keep every
# caller that builds a PR object on this helper so draft/label/staleness
# metadata cannot drift between list-based and webhook-triggered merge paths.
_pulse_merge_ready_pr_json_fields() {
	printf '%s' 'number,mergeable,reviewDecision,author,title,isDraft,labels,updatedAt,headRefOid,headRefName,baseRefName,createdAt,statusCheckRollup'
	return 0
}

# Source shared claim-lifecycle helpers (t2429). The _release_interactive_claim_on_merge
# function was extracted to shared-claim-lifecycle.sh so that both pulse-merge.sh and
# full-loop-helper.sh can call it after a successful PR merge. SCRIPT_DIR may not be set
# when this module is sourced by pulse-wrapper.sh; resolve from BASH_SOURCE.
_PULSE_MERGE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${_PULSE_MERGE_DIR}/shared-claim-lifecycle.sh"

# Source shared phase-filing helpers (t2740). auto_file_next_phase is called
# from _handle_post_merge_actions to auto-file the next phase child issue
# when a phase child PR merges for a parent-task issue.
source "${_PULSE_MERGE_DIR}/shared-phase-filing.sh"

# Source terminal dispatch-label cleanup (GH#24012). Close paths strip
# auto-dispatch and active status labels before closing resolved issues so
# stale closed issues cannot poison dispatch caches/candidate scans.
# shellcheck source=shared-dispatch-label-cleanup.sh
source "${_PULSE_MERGE_DIR}/shared-dispatch-label-cleanup.sh"

# Source shared supersession helpers (GH#24399). Merge-ready PRs that use a
# closing keyword against an issue already closed by a different merged PR are
# duplicate worker outputs and must be closed before any merge attempt.
# shellcheck source=pr-supersession-helper.sh
source "${_PULSE_MERGE_DIR}/pr-supersession-helper.sh"

# Targeted remediation for stale GitHub CLI HTTP cache entries that can make
# `gh pr merge` return a cached 401 even after live gh auth succeeds (GH#24656).
# shellcheck source=gh-merge-cache-remediation-lib.sh
# shellcheck disable=SC1091  # sub-library resolved at runtime via _PULSE_MERGE_DIR
source "${_PULSE_MERGE_DIR}/gh-merge-cache-remediation-lib.sh"

readonly _PM_PARENT_TASK_LABEL_NEEDLE=",parent-task,"

# Source author permission check helpers (GH#21426 — extracted to bring
# pulse-merge.sh below the 2000-line file-size-debt threshold).
# shellcheck source=./pulse-merge-author-checks.sh
# shellcheck disable=SC1091  # sub-library resolved at runtime via _PULSE_MERGE_DIR
source "${_PULSE_MERGE_DIR}/pulse-merge-author-checks.sh"

# Source PR gate checking functions (GH#21301 — extracted to bring
# pulse-merge.sh below the 1500-line file-size-debt threshold).
# shellcheck source=./pulse-merge-gates.sh
# shellcheck disable=SC1091  # sub-library resolved at runtime via _PULSE_MERGE_DIR
source "${_PULSE_MERGE_DIR}/pulse-merge-gates.sh"

# Source merge processing helpers (GH#21301 — extracted to bring
# pulse-merge.sh below the 1500-line file-size-debt threshold).
# shellcheck source=./pulse-merge-process.sh
# shellcheck disable=SC1091  # sub-library resolved at runtime via _PULSE_MERGE_DIR
source "${_PULSE_MERGE_DIR}/pulse-merge-process.sh"

# shellcheck source=./pulse-merge-required-checks.sh
# shellcheck disable=SC1091  # sub-library resolved at runtime via _PULSE_MERGE_DIR
source "${_PULSE_MERGE_DIR}/pulse-merge-required-checks.sh"

# _release_interactive_claim_on_merge is now provided by shared-claim-lifecycle.sh
# (sourced at the top of this module, t2429/GH#20067). The backward-compatible
# underscore-prefixed alias is defined there so all existing call sites
# (including _handle_post_merge_actions below) continue to work unchanged.

#######################################
# Handle terminal CHANGES_REQUESTED review state for a PR.
#
# Security invariant: this helper never marks a PR mergeable and never bypasses
# collaborator, maintainer, review-bot, or branch-protection gates. A blocking
# review either remains a skip/repair-route, or the existing maintainer-labeled
# CodeRabbit-only dismissal path clears stale bot nits before normal gates run.
#
# Returns 0 if the caller may keep evaluating merge gates.
# Returns 1 if the PR must be skipped for review feedback handling.
# Args: $1=pr_number, $2=repo_slug, $3=pr_review, $4=linked_issue, $5=pr_labels (optional), $6=dismissed_dest_var (optional)
#######################################
_handle_changes_requested_review_gate() {
	local pr_number="$1"
	local repo_slug="$2"
	local pr_review="$3"
	local linked_issue="$4"
	local pr_labels="${5:-}"
	local dismissed_dest_var="${6:-}"
	local _cr_pr_labels=""

	if [[ -n "$dismissed_dest_var" && "$dismissed_dest_var" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
		printf -v "$dismissed_dest_var" '%s' "0"
	fi

	[[ "$pr_review" == "CHANGES_REQUESTED" ]] || return 0

	# Fetch labels once — reused by both the nits-ok check and the
	# worker-routing block below.
	_cr_pr_labels="$pr_labels"
	if [[ $# -lt 5 && -z "$_cr_pr_labels" ]]; then
		_cr_pr_labels=$(gh_pr_view "$pr_number" --repo "$repo_slug" \
			--json labels --jq '[.labels[].name] | join(",")' 2>/dev/null) || _cr_pr_labels=""
	fi

	# t2179: coderabbit-nits-ok path.
	if [[ ",${_cr_pr_labels}," == *",coderabbit-nits-ok,"* ]]; then
		if _pulse_merge_dismiss_coderabbit_nits "$pr_number" "$repo_slug"; then
			echo "[pulse-wrapper] Merge pass: PR #${pr_number} in ${repo_slug} — auto-dismissed CodeRabbit-only CHANGES_REQUESTED reviews (coderabbit-nits-ok label) (t2179)" >>"$LOGFILE"
			if [[ -n "$dismissed_dest_var" && "$dismissed_dest_var" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
				printf -v "$dismissed_dest_var" '%s' "1"
			fi
			return 0
		fi

		echo "[pulse-wrapper] Merge pass: skipping PR #${pr_number} in ${repo_slug} — coderabbit-nits-ok label present but human reviewer also blocking (t2179)" >>"$LOGFILE"
		return 1
	fi

	local _cr_label_list=",${_cr_pr_labels},"
	# Optional policy override (GH#26535): by default CHANGES_REQUESTED worker
	# PRs keep the historical fast-routing behaviour below. Operators who prefer
	# preserving the PR/review context can opt in to a remediation-first cycle.
	if _pulse_merge_changes_requested_thread_remediation_first_enabled \
		&& [[ ( -n "${_OW_LABEL_PAT:-}" && "$_cr_label_list" == *"${_OW_LABEL_PAT:-}"* ) \
			|| "$_cr_label_list" == *",origin:worker-takeover,"* ]] \
		&& [[ "$_cr_label_list" != *",external-contributor,"* ]] \
		&& [[ "$_cr_label_list" != *",no-takeover,"* ]] \
		&& [[ "$_cr_label_list" != *",review-routed-to-issue,"* ]] \
		&& _pulse_merge_dispatch_review_thread_remediation "$pr_number" "$repo_slug" "after CHANGES_REQUESTED review gate"; then
		echo "[pulse-wrapper] Merge pass: skipping PR #${pr_number} in ${repo_slug} — reviewDecision=CHANGES_REQUESTED; review-thread remediation queued" >>"$LOGFILE"
		return 1
	fi

	# If remediation is unavailable or fails to dispatch, route worker-authored
	# PRs for fix dispatch and skip the merge (t2203: consolidated in helper).
	_route_pr_to_fix_worker "$pr_number" "$repo_slug" "$linked_issue" "review" "$_cr_pr_labels" || true
	echo "[pulse-wrapper] Merge pass: skipping PR #${pr_number} in ${repo_slug} — reviewDecision=CHANGES_REQUESTED" >>"$LOGFILE"
	return 1
}

#######################################
# Run all merge-eligibility gate checks for a single PR.
# Returns 0 if all gates pass (PR may proceed to merge).
# Returns 1 if any gate fails (PR should be skipped).
# Args: $1=pr_number, $2=repo_slug, $3=pr_author, $4=pr_review, $5=linked_issue, $6=pr_labels (optional)
#######################################
_check_pr_merge_gates() {
	local pr_number="$1"
	local repo_slug="$2"
	local pr_author="$3"
	local pr_review="$4"
	local linked_issue="$5"
	local pr_labels="${6:-}"

	# Skip CHANGES_REQUESTED — needs a fix worker, not a merge.
	#
	# t2093: For worker-authored PRs with a linked issue, the "skip and hope"
	# path leaks stuck PRs indefinitely — no human owns worker PRs, the
	# dispatch-dedup guard blocks re-dispatch while the PR is open, and the
	# review-followup pipeline only fires on *merged* PRs. Route the review
	# feedback to the linked issue body and close the PR so the next pulse
	# cycle picks the issue up with fresh context. Interactive PRs are
	# always left alone (their humans own the feedback loop); external
	# contributors go through their own crypto-approval flow.
	#
	# t2179: coderabbit-nits-ok override — if the maintainer applied the
	# label and EVERY CHANGES_REQUESTED reviewer is coderabbitai[bot],
	# auto-dismiss those reviews and fall through to the next gate. If any
	# human reviewer is also blocking, the label is ignored.
	if ! _handle_changes_requested_review_gate "$pr_number" "$repo_slug" "$pr_review" "$linked_issue" "$pr_labels"; then
		return 1
	fi

	# Skip external contributor PRs (non-collaborator).
	# t3063 crypto-approval bypass: a verified maintainer signature on the PR
	# or its linked issue is a stronger trust signal than author-association
	# (requires root-owned SSH key that workers cannot forge). Symmetric with
	# t3052 which extended the worker-briefed gate the same way (PR #21767).
	local _author_collab_rc=0
	_is_collaborator_author "$pr_author" "$repo_slug"
	_author_collab_rc=$?
	local _author_collab_permission="${_PULSE_AUTHOR_PERMISSION_VALUE:-}"
	if [[ "$_author_collab_rc" -eq 2 ]]; then
		check_permission_failure_pr "$pr_number" "$repo_slug" "$pr_author" "${_PULSE_AUTHOR_PERMISSION_HTTP:-unknown}" || true
		echo "[pulse-wrapper] Merge pass: skipping PR #${pr_number} in ${repo_slug} — permission check failed for author ${pr_author} (HTTP ${_PULSE_AUTHOR_PERMISSION_HTTP:-unknown})" >>"$LOGFILE"
		return 1
	fi
	if [[ "$_author_collab_rc" -ne 0 ]]; then
		if _is_trusted_dependabot_update_pr "$pr_number" "$repo_slug" "$pr_author"; then
			echo "[pulse-wrapper] Merge pass: PR #${pr_number} in ${repo_slug} — author ${pr_author} is trusted Dependabot with allowlisted dependency update, proceeding (GH#24473)" >>"$LOGFILE"
		elif _has_maintainer_crypto_approval "$pr_number" "$repo_slug"; then
			echo "[pulse-wrapper] Merge pass: PR #${pr_number} in ${repo_slug} — author ${pr_author} is not a collaborator but has maintainer crypto-approval, proceeding (t3063)" >>"$LOGFILE"
		else
			echo "[pulse-wrapper] Merge pass: skipping PR #${pr_number} in ${repo_slug} — author ${pr_author} is not a collaborator" >>"$LOGFILE"
			return 1
		fi
	fi

	# Skip PRs modifying workflow files when we lack the scope
	if check_pr_modifies_workflows "$pr_number" "$repo_slug" 2>/dev/null; then
		if ! check_gh_workflow_scope 2>/dev/null; then
			echo "[pulse-wrapper] Merge pass: skipping PR #${pr_number} in ${repo_slug} — modifies workflow files but token lacks workflow scope" >>"$LOGFILE"
			return 1
		fi
	fi

	# Maintainer-gate: skip if linked issue has needs-maintainer-review
	# UNLESS the issue also has the approval marker comment
	# (<!-- aidevops-signed-approval -->), which means the auto-approve
	# already ran and the NMR label is transient — the CI workflow
	# re-adds it within seconds of removal, creating a race with the
	# merge pass. The approval marker is the source of truth; NMR label
	# is the transient symptom of the CI workflow fighting the pulse.
	if [[ -n "$linked_issue" ]]; then
		local _li_api
		_li_api=$(_pm_issue_api "$repo_slug" "$linked_issue")
		local issue_labels
		issue_labels=$(gh api "${_li_api}" \
			--jq '[.labels[].name] | join(",")' 2>/dev/null) || issue_labels=""
		if [[ "$issue_labels" == *"needs-maintainer-review"* ]]; then
			# Check if approval marker exists — if so, NMR is transient
			local _has_approval_marker
			_has_approval_marker=$(gh api "${_li_api}/comments" \
				--jq '[.[].body | select(contains("aidevops-signed-approval"))] | length' \
				2>/dev/null) || _has_approval_marker=0
			if [[ "$_has_approval_marker" -gt 0 ]]; then
				echo "[pulse-wrapper] Merge pass: PR #${pr_number} in ${repo_slug} — linked issue #${linked_issue} has NMR but also approval marker — proceeding (NMR is transient)" >>"$LOGFILE"
			else
				echo "[pulse-wrapper] Merge pass: skipping PR #${pr_number} in ${repo_slug} — linked issue #${linked_issue} has needs-maintainer-review (no approval marker)" >>"$LOGFILE"
				return 1
			fi
		fi
	fi

	# ── External contributor gate (t1958) ──
	# Requires linked issue + crypto approval (defence-in-depth after _is_collaborator_author).
	local pr_labels_for_ext
	pr_labels_for_ext=$(gh_pr_view "$pr_number" --repo "$repo_slug" --json labels \
		--jq '[.labels[].name] | join(",")' 2>/dev/null) || pr_labels_for_ext=""
	if [[ "$pr_labels_for_ext" == *"external-contributor"* ]]; then
		if ! _external_pr_has_linked_issue "$pr_number" "$repo_slug"; then
			echo "[pulse-wrapper] Merge pass: skipping PR #${pr_number} in ${repo_slug} — external-contributor PR has no linked issue (t1958)" >>"$LOGFILE"
			return 1
		fi
		if ! _external_pr_linked_issue_crypto_approved "$pr_number" "$repo_slug"; then
			local ext_linked_for_log
			ext_linked_for_log=$(_extract_linked_issue "$pr_number" "$repo_slug" 2>/dev/null) || ext_linked_for_log="unknown"
			echo "[pulse-wrapper] Merge pass: skipping PR #${pr_number} in ${repo_slug} — external-contributor PR linked issue #${ext_linked_for_log} lacks crypto approval (t1958)" >>"$LOGFILE"
			return 1
		fi
	fi

	# ── origin:interactive gates (t2411/GH#23238) ──
	# Draft, hold-for-review, and throughput-preference checks for interactive PRs. Applies
	# to all interactive PRs regardless of author role (OWNER, MEMBER, or
	# COLLABORATOR). COLLABORATORs that pass these checks still go through the
	# review bot gate and normal merge path without an ownership fast-path.
	local _oi_info_json _oi_labels_str _oi_is_draft
	_oi_info_json=$(gh_pr_view "$pr_number" --repo "$repo_slug" \
		--json labels,isDraft 2>/dev/null) || _oi_info_json=""
	_oi_labels_str=$(printf '%s' "$_oi_info_json" \
		| jq -r '[.labels[].name] | join(",")' 2>/dev/null) || _oi_labels_str=""
	_oi_is_draft=$(printf '%s' "$_oi_info_json" \
		| jq -r '.isDraft // false' 2>/dev/null) || _oi_is_draft="false"
	if [[ "$_oi_labels_str" == *"origin:interactive"* ]]; then
		if ! _check_interactive_pr_gates "$pr_number" "$repo_slug" "$_oi_labels_str" "$_oi_is_draft"; then
			return 1
		fi
	fi

	# ── origin:worker worker-briefed gates (t2449) ──
	# Symmetric to the origin:interactive auto-merge gate (t2411). When a
	# worker PR is backed by a maintainer-briefed issue (OWNER/MEMBER author),
	# the trust chain is equivalent to an interactive session. This gate
	# validates the additional criteria beyond the general gates.
	#
	# Uses comma-delimited matching: ",origin:worker," does NOT match
	# ",origin:worker-takeover," (substring-safe).
	if [[ -n "${_OW_LABEL_PAT:-}" && ",${_oi_labels_str}," == *"${_OW_LABEL_PAT:-}"* ]]; then
		if ! _attempt_worker_briefed_auto_merge "$pr_number" "$repo_slug" "$_oi_labels_str" "$_oi_is_draft" "$linked_issue" "$_author_collab_permission" "$pr_author"; then
			return 1
		fi
	fi

	# ── Review bot gate (GH#17490) ──
	# --admin bypasses branch protection; enforce in code (see review-bot-gate-helper.sh).
	local rbg_helper="${AGENTS_DIR:-$HOME/.aidevops/agents}/scripts/review-bot-gate-helper.sh"
	if [[ -f "$rbg_helper" ]]; then
		if _is_trusted_dependabot_update_pr "$pr_number" "$repo_slug" "$pr_author"; then
			echo "[pulse-wrapper] Review bot gate: SKIP for trusted Dependabot dependency update PR #${pr_number} in ${repo_slug} (GH#24473)" >>"$LOGFILE"
			return 0
		fi
		local rbg_result="" rbg_status=""
		rbg_result=$(bash "$rbg_helper" check "$pr_number" "$repo_slug" 2>/dev/null) || rbg_result=""
		rbg_status=$(printf '%s' "$rbg_result" | grep -oE '^(PASS|SKIP|WAITING|PASS_RATE_LIMITED)' | head -1)
		case "$rbg_status" in
		PASS | SKIP | PASS_RATE_LIMITED)
			echo "[pulse-wrapper] Review bot gate: ${rbg_status} for PR #${pr_number} in ${repo_slug}" >>"$LOGFILE"
			;;
		*)
			echo "[pulse-wrapper] Review bot gate: ${rbg_status:-UNKNOWN} for PR #${pr_number} in ${repo_slug} — skipping merge" >>"$LOGFILE"
			return 1
			;;
		esac
	fi

	return 0
}

#######################################
# Perform all post-merge actions for a successfully merged PR:
# build and post closing comment, close linked issue, unlock.
# Best-effort — failures are logged but do not propagate.
# Args: $1=pr_number, $2=repo_slug, $3=linked_issue, $4=merge_summary, $5=pr_labels, $6=pr_base_ref_name (optional)
#######################################
#######################################
# Build the closing comment body for a merged PR (with signature footer).
# Args: $1=pr_number, $2=repo_slug, $3=linked_issue, $4=merge_summary, $5=pr_base_ref_name (optional)
# Stdout: closing comment body
# Returns: 0 always
#######################################
_pm_build_closing_comment() {
	local pr_number="$1" repo_slug="$2" linked_issue="$3" merge_summary="$4"
	local pr_base_ref_name="${5:-main}"
	local body
	if [[ -n "$merge_summary" ]]; then
		body="${merge_summary}

---
Merged via PR #${pr_number} to ${pr_base_ref_name}.
_Merged by deterministic merge pass (pulse-wrapper.sh)._"
	else
		body="Completed via PR #${pr_number}, merged to ${pr_base_ref_name}.

_Merged by deterministic merge pass (pulse-wrapper.sh). Neither MERGE_SUMMARY comment nor PR body text was available._"
	fi

	local elapsed issue_ref="" footer
	elapsed=$(($(date +%s) - PULSE_START_EPOCH))
	[[ -n "$linked_issue" ]] && issue_ref="${repo_slug}#${linked_issue}"
	local sig_helper="${AGENTS_DIR:-$HOME/.aidevops/agents}/scripts/gh-signature-helper.sh"
	footer=$("$sig_helper" footer \
		--body "$body" --no-session --tokens 0 \
		--time "$elapsed" --session-type routine \
		${issue_ref:+--issue "$issue_ref"} --solved 2>/dev/null || true)
	printf '%s%s' "$body" "$footer"
	return 0
}

#######################################
# Resolve the original issue behind a superseded PR reference.
#
# When PR B resolves PR A, GitHub treats PR A as an issue number. The normal
# linked-issue extractor therefore returns PR A's number, not the issue that PR A
# originally resolved. If PR A is actually a pull request, inspect PR A and return
# its own linked issue so deterministic merge can close the original task too.
#
# Args: $1=merged_pr_number, $2=repo_slug, $3=candidate_issue_number
# Stdout: original issue number, or empty when candidate is not a PR chain
# Returns: 0 always
#######################################
_pm_resolve_superseded_original_issue() {
	local merged_pr_number="$1" repo_slug="$2" candidate_issue="$3"
	local original_issue

	[[ -z "$candidate_issue" ]] && return 0
	if ! gh api "repos/${repo_slug}/pulls/${candidate_issue}" >/dev/null 2>&1; then
		return 0
	fi

	original_issue=$(_extract_linked_issue "$candidate_issue" "$repo_slug" 2>/dev/null) || original_issue=""
	if [[ -z "$original_issue" || "$original_issue" == "$candidate_issue" || "$original_issue" == "$merged_pr_number" ]]; then
		return 0
	fi

	printf '%s' "$original_issue"
	return 0
}

#######################################
# Extract a non-closing parent/umbrella reference from a PR body.
#
# Args: $1=pr_number, $2=repo_slug
# Stdout: issue number from the first `For #NNN` / `Ref #NNN` reference
# Returns: 0 always
#######################################
_pm_extract_partial_parent_reference() {
	local pr_number="$1"
	local repo_slug="$2"
	local pr_body="" parent_issue=""

	pr_body=$(gh_pr_view "$pr_number" --repo "$repo_slug" --json body --jq '.body // empty' 2>/dev/null) || pr_body=""
	parent_issue=$(printf '%s' "$pr_body" | grep -ioE '(^|[[:space:]])(for|ref)[[:space:]]+#[0-9]+' | head -1 | grep -oE '[0-9]+') || parent_issue=""
	printf '%s' "$parent_issue"
	return 0
}

#######################################
# Decide whether an issue is broad enough to require partial closeout hygiene.
#
# Args: $1=issue body, $2=comma-separated label names
# Returns: 0=broad parent/umbrella issue, 1=normal leaf issue
#######################################
_pm_issue_needs_partial_closeout() {
	local issue_body="$1"
	local issue_labels="$2"
	local checklist_count

	if [[ ",${issue_labels}," == *"${_PM_PARENT_TASK_LABEL_NEEDLE}"* ]]; then
		return 0
	fi

	if printf '%s' "$issue_body" | grep -qiE '\b(parent|umbrella|roadmap|lifecycle|incident|acceptance criteria)\b'; then
		return 0
	fi

	checklist_count=$(printf '%s' "$issue_body" | grep -cE '^[[:space:]]*[-*+][[:space:]]*\[[ xX]\]' || true)
	[[ "$checklist_count" =~ ^[0-9]+$ ]] || checklist_count=0
	if [[ "$checklist_count" -ge 2 ]]; then
		return 0
	fi

	return 1
}

#######################################
# Extract unchecked acceptance criteria as a Markdown bullet list.
#
# Args: $1=issue body
# Stdout: bullet list (or a conservative fallback)
# Returns: 0 always
#######################################
_pm_unmet_acceptance_criteria() {
	local issue_body="$1"
	local criteria

	criteria=$(printf '%s' "$issue_body" | sed -nE 's/^[[:space:]]*[-*+][[:space:]]*\[[[:space:]]\][[:space:]]*/- /p' | head -20) || criteria=""
	if [[ -z "$criteria" ]]; then
		criteria="- Review the parent issue acceptance criteria and file worker-ready child issues for remaining scope."
	fi
	printf '%s' "$criteria"
	return 0
}

#######################################
# Post partial parent/umbrella closeout when a For/Ref PR merges.
#
# Non-closing references intentionally do not flow through the normal linked
# issue close path. This helper keeps the parent open but leaves an explicit
# closeout trail naming delivered work and follow-ups so broad parents are not
# left ambiguous after a leaf PR merge (GH#23937).
#
# Args: $1=pr_number, $2=repo_slug, $3=merge_summary, $4=linked_issue (optional)
# Returns: 0 always (best-effort post-merge hygiene)
#######################################
_pm_handle_partial_parent_closeout() {
	local pr_number="$1"
	local repo_slug="$2"
	local merge_summary="$3"
	local linked_issue="${4:-}"
	local parent_issue="" issue_api="" issue_json="" issue_body="" issue_labels="" dedup_count="" followups="" delivered_body=""

	parent_issue=$(_pm_extract_partial_parent_reference "$pr_number" "$repo_slug") || parent_issue=""
	[[ -n "$parent_issue" ]] || return 0
	[[ "$parent_issue" != "$linked_issue" ]] || return 0

	issue_api=$(_pm_issue_api "$repo_slug" "$parent_issue")
	issue_json=$(gh api "$issue_api" 2>/dev/null) || issue_json=""
	if [[ -z "$issue_json" ]]; then
		return 0
	fi
	local _RS=$'\x1e'
	IFS="$_RS" read -r -d '' issue_body issue_labels < <(
		printf '%s' "$issue_json" | jq -j --arg rs "$_RS" \
			'(.body // ""), $rs, ([.labels[]?.name] | join(",")), "\u0000"'
	) || true

	if ! _pm_issue_needs_partial_closeout "$issue_body" "$issue_labels"; then
		return 0
	fi

	dedup_count=$(gh api "${issue_api}/comments" 2>/dev/null | jq --arg marker "PARTIAL_PARENT_CLOSEOUT:PR#${pr_number}" '[.[] | select(.body | contains($marker))] | length' 2>/dev/null) || dedup_count=0
	[[ "$dedup_count" =~ ^[0-9]+$ ]] || dedup_count=0
	if [[ "$dedup_count" -gt 0 ]]; then
		echo "[pulse-wrapper] Deterministic merge: skipped duplicate partial parent closeout on #${parent_issue} for PR #${pr_number} (GH#23937)" >>"$LOGFILE"
		return 0
	fi

	followups=$(_pm_unmet_acceptance_criteria "$issue_body")
	delivered_body="${merge_summary:-PR #${pr_number} merged as a leaf delivery.}"

	local partial_comment
	partial_comment="<!-- PARTIAL_PARENT_CLOSEOUT:PR#${pr_number} -->
## Partial Parent Closeout

PR #${pr_number} merged against this broad parent using a non-closing \`For #${parent_issue}\` / \`Ref #${parent_issue}\` reference, so the parent remains open.

### Delivered

${delivered_body}

### Follow-ups still requiring closure or child issues

${followups}

### Closeout rule

Do not close this parent until the remaining acceptance criteria are covered by merged release evidence or an explicit maintainer/operator closeout decision is posted."

	gh_issue_comment "$parent_issue" --repo "$repo_slug" --body "$partial_comment" 2>/dev/null || true
	echo "[pulse-wrapper] Deterministic merge: posted partial parent closeout on issue #${parent_issue} for PR #${pr_number} (GH#23937)" >>"$LOGFILE"
	return 0
}

_handle_post_merge_actions() {
	local pr_number="$1"
	local repo_slug="$2"
	local linked_issue="$3"
	local merge_summary="$4"
	local pr_labels="${5:-}"
	local pr_base_ref_name="${6:-main}"

	local closing_comment
	closing_comment=$(_pm_build_closing_comment "$pr_number" "$repo_slug" \
		"$linked_issue" "$merge_summary" "$pr_base_ref_name")

	# Post closing comment on PR; unlock the merged PR (t1934)
	gh_pr_comment "$pr_number" --repo "$repo_slug" \
		--body "$closing_comment" 2>/dev/null || true
	unlock_issue_after_worker "$pr_number" "$repo_slug"

	# Close linked issue with the same closing comment
	if [[ -n "$linked_issue" ]]; then
		# t2099 / GH#19032: parent-task close guard. Parent roadmap issues must
		# stay open until ALL phase children merge (t2046). The PR-body keyword
		# guard prevents workers from writing Closes/Resolves/Fixes against a
		# parent, and they instead use "For #NNN" / "Ref #NNN". BUT
		# `_extract_linked_issue` also falls back to matching `GH#NNN:` in
		# the PR title — which is the canonical PR title format for
		# parent-task phase PRs. Without this check, every phase PR would
		# silently close its parent on merge.
		#
		# Behaviour:
		#   - Still post the closing comment (it doubles as a phase-merged
		#     status update on the parent).
		#   - SKIP the `gh issue close` call.
		#   - SKIP fast_fail_reset and unlock (both tied to closing).
		local _parent_task_guard=0
		local _pm_li_api
		_pm_li_api=$(_pm_issue_api "$repo_slug" "$linked_issue")
		local _linked_labels
		_linked_labels=$(gh api "${_pm_li_api}" \
			--jq '[.labels[].name] | join(",")' 2>/dev/null) || _linked_labels=""
		if [[ ",${_linked_labels}," == *"${_PM_PARENT_TASK_LABEL_NEEDLE}"* ]]; then
			_parent_task_guard=1
			echo "[pulse-wrapper] Deterministic merge: skipping close of parent-task issue #${linked_issue} (PR #${pr_number} is a phase child; parent stays open until all phases merge) — t2099/GH#19032" >>"$LOGFILE"
		fi

		# Dedup guard: skip if closing comment for this PR already exists (GH#18098).
		local _dedup_count
		_dedup_count=$(gh api "${_pm_li_api}/comments" \
			2>/dev/null | jq --arg prnum "PR #${pr_number}" \
			'[.[] | select(.body | contains($prnum))] | length' 2>/dev/null) || _dedup_count=0
		[[ "$_dedup_count" =~ ^[0-9]+$ ]] || _dedup_count=0
		if [[ "$_dedup_count" -gt 0 ]]; then
			echo "[pulse-wrapper] Deterministic merge: skipped duplicate closing comment on #${linked_issue} — PR #${pr_number} already referenced in existing comment (GH#18098)" >>"$LOGFILE"
		else
			gh_issue_comment "$linked_issue" --repo "$repo_slug" \
				--body "$closing_comment" 2>/dev/null || true
		fi

		if [[ "$_parent_task_guard" -eq 0 ]]; then
			if [[ $# -lt 5 ]]; then
				pr_labels=$(gh_pr_view "$pr_number" --repo "$repo_slug" \
					--json labels --jq '[.labels[].name] | join(",")' 2>/dev/null) || pr_labels=""
			fi
			local _solved_actor="interactive"
			case ",${pr_labels}," in
			*,origin:worker,* | *,origin:worker-takeover,*) _solved_actor="worker" ;;
			esac
			set_solved_label "$linked_issue" "$repo_slug" "$_solved_actor" || true
			clear_terminal_issue_dispatch_labels "$linked_issue" "$repo_slug" "post-merge-pr-${pr_number}" || true
			gh issue close "$linked_issue" --repo "$repo_slug" 2>/dev/null || true
			# Reset fast-fail counter now that the issue is resolved (GH#2076)
			fast_fail_reset "$linked_issue" "$repo_slug" || true
			# t1934: Unlock the issue (locked at dispatch time)
			unlock_issue_after_worker "$linked_issue" "$repo_slug"
		fi

		# GH#22964: if the merged PR resolved a superseded worker PR, also close
		# the original issue that the superseded PR was created to resolve.
		local _superseded_original_issue
		_superseded_original_issue=$(_pm_resolve_superseded_original_issue \
			"$pr_number" "$repo_slug" "$linked_issue") || _superseded_original_issue=""
		if [[ -n "$_superseded_original_issue" ]]; then
			local _sup_api _sup_labels _sup_parent_guard=0 _sup_dedup_count
			_sup_api=$(_pm_issue_api "$repo_slug" "$_superseded_original_issue")
			_sup_labels=$(gh api "${_sup_api}" \
				--jq '[.labels[].name] | join(",")' 2>/dev/null) || _sup_labels=""
			if [[ ",${_sup_labels}," == *"${_PM_PARENT_TASK_LABEL_NEEDLE}"* ]]; then
				_sup_parent_guard=1
				echo "[pulse-wrapper] Deterministic merge: skipping close of parent-task original issue #${_superseded_original_issue} via superseded PR #${linked_issue} (merged PR #${pr_number}) — GH#22964" >>"$LOGFILE"
			fi

			_sup_dedup_count=$(gh api "${_sup_api}/comments" \
				2>/dev/null | jq --arg prnum "PR #${pr_number}" \
				'[.[] | select(.body | contains($prnum))] | length' 2>/dev/null) || _sup_dedup_count=0
			[[ "$_sup_dedup_count" =~ ^[0-9]+$ ]] || _sup_dedup_count=0
			if [[ "$_sup_dedup_count" -gt 0 ]]; then
				echo "[pulse-wrapper] Deterministic merge: skipped duplicate closing comment on original issue #${_superseded_original_issue} via superseded PR #${linked_issue} — PR #${pr_number} already referenced (GH#22964)" >>"$LOGFILE"
			else
				gh_issue_comment "$_superseded_original_issue" --repo "$repo_slug" \
					--body "$closing_comment" 2>/dev/null || true
			fi

			if [[ "$_sup_parent_guard" -eq 0 ]]; then
				local _sup_solved_actor="interactive"
				case ",${pr_labels}," in
				*,origin:worker,* | *,origin:worker-takeover,*) _sup_solved_actor="worker" ;;
				esac
				set_solved_label "$_superseded_original_issue" "$repo_slug" "$_sup_solved_actor" || true
				clear_terminal_issue_dispatch_labels "$_superseded_original_issue" "$repo_slug" "post-merge-superseded-pr-${pr_number}" || true
				gh issue close "$_superseded_original_issue" --repo "$repo_slug" 2>/dev/null || true
				fast_fail_reset "$_superseded_original_issue" "$repo_slug" || true
				unlock_issue_after_worker "$_superseded_original_issue" "$repo_slug"
			fi
		fi
	fi

	# Post partial parent closeout if a For/Ref reference exists (GH#23937).
	_pm_handle_partial_parent_closeout "$pr_number" "$repo_slug" "$merge_summary" "$linked_issue"

	# Auto-release interactive claim if one exists for this issue (t2413).
	# Handles the "when a PR they opened merges" release trigger from AGENTS.md
	# so the agent does not have to remember to call release after every merge.
	_release_interactive_claim_on_merge "$pr_number" "$repo_slug" "$linked_issue"

	# Sequential phase auto-filing (t2740 — Gap C): when a phase child PR
	# merges and its linked child issue is closed, inspect the parent-task
	# issue's ## Phases section and auto-file the next phase. Only fires
	# when AIDEVOPS_SEQUENTIAL_PHASE_AUTOFILE=1. Best-effort — failures
	# are logged but do not block the merge completion path.
	if [[ -n "$linked_issue" && "${_parent_task_guard:-0}" -eq 0 ]]; then
		auto_file_next_phase "$linked_issue" "$repo_slug" || true
	fi

	_unblock_circuit_breaker_meta_pr "$linked_issue" "$repo_slug" "${_linked_labels:-}"

	declare -F invalidate_footprint_cache_for_issue >/dev/null 2>&1 && invalidate_footprint_cache_for_issue "${linked_issue:-}" || true
	return 0
}

#######################################
# Circuit-breaker meta-PR cleanup hook (t3076). When the merged PR's
# linked issue carries `circuit-breaker-meta`, delegate to the filer's
# unblock-on-merge subcommand: remove blocked-by:#<meta> from the
# original, clear NMR if no other breaker markers remain, post an
# unblock comment. Idempotent. Best-effort — failures never block
# the merge completion path.
#
# Args: $1=linked_issue, $2=repo_slug, $3=comma-padded labels CSV
# Returns: 0 always
#######################################
_unblock_circuit_breaker_meta_pr() {
	local linked_issue="$1"
	local repo_slug="$2"
	local labels_csv="$3"

	[[ -z "$linked_issue" ]] && return 0
	[[ ",${labels_csv}," == *",circuit-breaker-meta,"* ]] || return 0

	local filer="${AGENTS_DIR:-$HOME/.aidevops/agents}/scripts/circuit-breaker-meta-filer.sh"
	[[ -x "$filer" ]] || return 0

	"$filer" unblock-on-merge \
		--meta "$linked_issue" --repo "$repo_slug" >>"$LOGFILE" 2>&1 || true
	return 0
}

_pm_pr_labels_mark_intentional_followup() {
	local pr_labels_csv="$1"
	local labels_padded=",${pr_labels_csv},"

	case "$labels_padded" in
	*,intentional-follow-up,* | *,follow-up,* | *,do-not-close,* | *,hold-for-review,* | *,no-auto-dispatch,* | *,needs-maintainer-review,*)
		return 0
		;;
	esac
	return 1
}

_pm_close_superseded_duplicate_pr_if_issue_solved() {
	local pr_number="$1"
	local repo_slug="$2"
	local linked_issue="$3"
	local pr_labels_csv="$4"

	[[ "$linked_issue" =~ ^[0-9]+$ ]] || return 1
	case ",${pr_labels_csv}," in
	*,origin:worker,* | *,origin:worker-takeover,*) ;;
	*) return 1 ;;
	esac
	if _pm_pr_labels_mark_intentional_followup "$pr_labels_csv"; then
		echo "[pulse-wrapper] Merge pass: PR #${pr_number} in ${repo_slug} links closed issue #${linked_issue} but has intentional-follow-up/protection label; not closing as duplicate (GH#24399)" >>"$LOGFILE"
		return 1
	fi

	local superseding_pr
	superseding_pr=$(_psh_find_merged_closer_for_closed_issue "$repo_slug" "$linked_issue" "$pr_number" 2>/dev/null) || superseding_pr=""
	[[ "$superseding_pr" =~ ^[0-9]+$ ]] || return 1

	gh pr close "$pr_number" --repo "$repo_slug" \
		--comment "Closing as superseded: linked issue #${linked_issue} is already closed by merged PR #${superseding_pr}. This worker PR uses a closing keyword for the same issue, so merging it would duplicate an already-terminal fix.

Intentional follow-ups should use For #${linked_issue} / Ref #${linked_issue} or an explicit follow-up/protection label instead of a closing keyword.

_Closed by deterministic merge pass (GH#24399)._" 2>/dev/null || true
	unlock_issue_after_worker "$pr_number" "$repo_slug"
	echo "[pulse-wrapper] Merge pass: closed superseded duplicate PR #${pr_number} in ${repo_slug} — issue #${linked_issue} already closed by merged PR #${superseding_pr} (GH#24399)" >>"$LOGFILE"
	return 0
}

#######################################
# Process a single PR end-to-end: gate checks, merge attempt,
# conflict detection, and closing comment posting.
#
# Extracted from _merge_ready_prs_for_repo (t2002 / GH#18450, Phase 12).
# Decomposed into focused helpers (GH#18682): _resolve_pr_mergeable_status,
# _check_pr_merge_gates, _handle_post_merge_actions.
# Enables per-PR debugging and unit testing in isolation.
#
# Args:
#   $1 - repo slug
#   $2 - PR JSON object (single element from gh pr list --json output)
# Returns:
#   0 = merged successfully
#   1 = skipped (gate failure or non-mergeable)
#   2 = closed conflicting
#   3 = merge failed
#   4 = native auto-merge requested/deferred; no merge completed this cycle
#######################################
_process_single_ready_pr() {
	local repo_slug="$1"
	local pr_obj="$2"
	local timing_prefix="${3:-}"

	local pr_number="" pr_mergeable="" pr_review="" pr_author="" pr_title="" pr_updated_at="" pr_head_ref_oid="" pr_head_ref_name="" pr_base_ref_name="" pr_labels="" pr_is_draft="false"
	local _mergeability_start="" _branch_protection_start="" _ruleset_start=""
	# Consolidate into a single jq pass to reduce process-spawn overhead.
	# CRITICAL: use non-whitespace delimiter (ASCII 0x1E record separator)
	# instead of \t. Bash read collapses consecutive IFS whitespace chars
	# (tab, space, newline) — if ANY field is empty the subsequent fields
	# shift left. reviewDecision is routinely "" (empty string, which jq //
	# does NOT catch — it only triggers on null/false). The field shift
	# caused pr_author to receive the PR title, breaking the collaborator
	# check and blocking ALL merges across every repo (observed downstream).
	local _RS=$'\x1e'
	IFS="$_RS" read -r pr_number pr_mergeable pr_review pr_author pr_title pr_updated_at pr_head_ref_oid pr_head_ref_name pr_base_ref_name pr_labels pr_is_draft < <(
		printf '%s' "$pr_obj" | jq -r \
			'"\(.number // "")\u001e\(.mergeable // "UNKNOWN")\u001e\(if ((has("reviewDecision") | not) or .reviewDecision == null or (.reviewDecision | tostring | length) == 0) then "UNKNOWN" else .reviewDecision end)\u001e\(.author.login // "unknown")\u001e\(.title // "")\u001e\(.updatedAt // "")\u001e\(.headRefOid // "")\u001e\(.headRefName // "")\u001e\(.baseRefName // "")\u001e\([(.labels // [])[].name] | join(","))\u001e\(.isDraft // false | tostring)"'
	)
	_pmp_normalize_mergeable_state_into pr_mergeable "$pr_mergeable"
	_pmp_normalize_review_decision_into pr_review "$pr_review"
	[[ -n "$timing_prefix" ]] && _mergeability_start=$(_pmp_now_epoch)

	[[ "$pr_number" =~ ^[0-9]+$ ]] || return 1
	if [[ "$pr_is_draft" == "true" ]]; then
		echo "[pulse-wrapper] Merge pass: skipping PR #${pr_number} in ${repo_slug} — draft PR not eligible for auto-merge (GH#23525)" >>"$LOGFILE"
		return 1
	fi

	# REST-first PR lists cannot preserve GraphQL-only mergeable state, so a
	# truly conflicting PR may enter this function as UNKNOWN. Refresh that
	# value before the CONFLICTING branch; otherwise the later non-MERGEABLE
	# skip makes conflict remediation unreachable (GH#24634).
	if [[ "$pr_mergeable" == UNKNOWN || -z "$pr_mergeable" ]]; then
		_pmp_refresh_unknown_mergeable_state_into pr_mergeable "$pr_number" "$repo_slug" "$pr_mergeable"
	fi
	if _pmp_review_decision_is_unknown "$pr_review"; then
		_pmp_refresh_unknown_review_decision_into pr_review "$pr_number" "$repo_slug" "$pr_review"
	fi

	# CONFLICTING handling (t2116): before closing, attempt to salvage the
	# PR via `gh pr update-branch` which fast-forwards the base branch into
	# the PR's branch when the conflict is purely due to base advancement
	# (common case: ratchet PRs on a file that other PRs also touched, docs
	# simplifications on adjacent sections). If update-branch succeeds, the
	# PR may now be MERGEABLE and we re-fetch its state so the normal merge
	# path can take over in the same cycle.
	#
	# This reorders the original flow: we now also check the maintainer gate
	# BEFORE closing, so PRs waiting on `needs-maintainer-review` are never
	# discarded as CONFLICTING during their wait (previous behaviour punished
	# maintainer review latency by throwing away worker work — see t2116
	# post-mortem for PR #18988, #19083).
	if [[ "$pr_mergeable" == "CONFLICTING" && "$PULSE_MERGE_CLOSE_CONFLICTING" == "true" ]]; then
		# Skip CONFLICTING-close entirely for PRs whose linked issue has
		# needs-maintainer-review — they are parked legitimately waiting for
		# a human and MUST NOT be auto-closed (t2116). Post the one-time
		# rebase nudge so the maintainer has a visible signal.
		local _t2116_linked_issue _t2116_issue_labels
		_t2116_linked_issue=$(_extract_linked_issue "$pr_number" "$repo_slug")
		if [[ -n "$_t2116_linked_issue" ]]; then
			_t2116_issue_labels=$(gh api "repos/${repo_slug}/issues/${_t2116_linked_issue}" \
				--jq '[.labels[].name] | join(",")' 2>/dev/null) || _t2116_issue_labels=""
			if [[ "$_t2116_issue_labels" == *"needs-maintainer-review"* ]]; then
				echo "[pulse-wrapper] Merge pass: skipping CONFLICTING-close of PR #${pr_number} in ${repo_slug} — linked issue #${_t2116_linked_issue} has needs-maintainer-review (t2116)" >>"$LOGFILE"
				_post_rebase_nudge_on_worker_conflicting "$pr_number" "$repo_slug" "" "" 2>/dev/null || true
				[[ -n "$timing_prefix" ]] && _pmp_add_elapsed_seconds "${timing_prefix}mergeability_s" "$_mergeability_start"
				return 1
			fi
		fi

		# Attempt auto-rebase via gh pr update-branch. This is idempotent
		# and cheap: on success the branch is fast-forwarded and the next
		# mergeable re-fetch returns MERGEABLE; on failure (true semantic
		# conflict) we fall through to the close path.
		if _attempt_pr_update_branch "$pr_number" "$repo_slug"; then
			# Re-fetch mergeable state after update-branch; GitHub needs a
			# moment to recompute it. _resolve_pr_mergeable_status already
			# has a UNKNOWN-retry loop so we reuse it.
			local _refetched_mergeable
			_refetched_mergeable=$(gh_pr_view "$pr_number" --repo "$repo_slug" \
				--json mergeable --jq '.mergeable // "UNKNOWN"' 2>/dev/null) || _refetched_mergeable="UNKNOWN"
			pr_mergeable="$_refetched_mergeable"
			_pmp_normalize_mergeable_state_into pr_mergeable "$pr_mergeable"
			echo "[pulse-wrapper] Merge pass: PR #${pr_number} in ${repo_slug} — update-branch succeeded, refetched mergeable=${pr_mergeable} (t2116)" >>"$LOGFILE"
			# If still CONFLICTING after a successful update-branch, the
			# conflict is semantic and unsalvageable. Fall through to close.
		fi

			if [[ "$pr_mergeable" == "CONFLICTING" ]]; then
			# Conflict resolution feedback: route worker PRs and stale interactive
			# PRs to fix workers before the protected-close precheck. Active
			# interactive PRs remain protected because _route_pr_to_fix_worker only
			# accepts origin:interactive after _interactive_pr_is_stale passes.
				if _route_pr_to_fix_worker "$pr_number" "$repo_slug" "$_t2116_linked_issue" "conflict" "$pr_labels" "$pr_title" "$pr_updated_at" "$pr_head_ref_oid"; then
					[[ -n "$timing_prefix" ]] && _pmp_add_elapsed_seconds "${timing_prefix}mergeability_s" "$_mergeability_start"
					return 2
				fi

			# GH#23371: some PRs are already known to be protected from
			# automated close handling from the PR list metadata (draft,
			# origin:interactive, no-auto-dispatch, external-contributor).
			# Skip them before the close-conflict ownership guard so pulse
			# does not repeatedly hit the noisy metadata-fetch path.
				if _close_conflicting_pr_skip_protected_precheck "$pr_number" "$repo_slug" "$pr_obj"; then
					[[ -n "$timing_prefix" ]] && _pmp_add_elapsed_seconds "${timing_prefix}mergeability_s" "$_mergeability_start"
					return 1
				fi

				_close_conflicting_pr "$pr_number" "$repo_slug" "$pr_title"
				[[ -n "$timing_prefix" ]] && _pmp_add_elapsed_seconds "${timing_prefix}mergeability_s" "$_mergeability_start"
				return 2
			fi
		# Otherwise pr_mergeable is now MERGEABLE/UNKNOWN — continue through
		# the normal merge path below.
	fi

	# Review feedback routing is terminal for worker PRs and must not wait for
	# GitHub's mergeable cache to resolve. Keep this narrow: conflict handling
	# above retains precedence, and the helper only routes/skips CHANGES_REQUESTED
	# via the same origin-label and stale-handover checks used by the later gate.
	if [[ "$pr_review" == "CHANGES_REQUESTED" ]]; then
		local _early_review_linked_issue=""
		local _early_review_dismissed="0"
		_early_review_linked_issue=$(_extract_linked_issue "$pr_number" "$repo_slug" 2>/dev/null) || _early_review_linked_issue=""
		if ! _handle_changes_requested_review_gate "$pr_number" "$repo_slug" "$pr_review" "$_early_review_linked_issue" "$pr_labels" _early_review_dismissed; then
			[[ -n "$timing_prefix" ]] && _pmp_add_elapsed_seconds "${timing_prefix}mergeability_s" "$_mergeability_start"
			return 1
		fi
		# Avoid re-processing stale CHANGES_REQUESTED metadata in the later merge
		# gate after the CodeRabbit-only dismissal path has already succeeded.
		if [[ "$_early_review_dismissed" == "1" ]]; then
			pr_review="NONE"
		fi
	fi
	if _pmp_review_decision_is_unknown "$pr_review"; then
		echo "[pulse-wrapper] Merge pass: skipping PR #${pr_number} in ${repo_slug} — reviewDecision unavailable after refresh (GH#26218)" >>"$LOGFILE"
		return 1
	fi

	# Resolve UNKNOWN mergeable state with one retry; skip if not MERGEABLE
	if ! _resolve_pr_mergeable_status "$pr_number" "$repo_slug" "$pr_mergeable"; then
		[[ -n "$timing_prefix" ]] && _pmp_add_elapsed_seconds "${timing_prefix}mergeability_s" "$_mergeability_start"
		return 1
	fi
	[[ -n "$timing_prefix" ]] && _pmp_add_elapsed_seconds "${timing_prefix}mergeability_s" "$_mergeability_start"

	# Fetch linked issue once — used in gate checks, CI repair routing, and
	# post-merge close. Do this before CI repair routing so red/pending PRs are
	# only re-queued after the same maintainer/security/review-bot gates that
	# would have allowed a green merge have passed.
	local linked_issue
	linked_issue=$(_extract_linked_issue "$pr_number" "$repo_slug")

	# Run all skip-gate checks (review decision, collaborator, workflow scope,
	# maintainer gate, external-contributor gate, review bot gate) before both
	# merge and CI-repair paths. This preserves the security boundary for PRs
	# that are red but would not be trusted if green.
	if ! _check_pr_merge_gates "$pr_number" "$repo_slug" "$pr_author" "$pr_review" "$linked_issue" "$pr_labels"; then
		return 1
	fi

	if declare -F _pm_close_superseded_duplicate_pr_if_issue_solved >/dev/null 2>&1 \
		&& _pm_close_superseded_duplicate_pr_if_issue_solved "$pr_number" "$repo_slug" "$linked_issue" "$pr_labels"; then
		return 1
	fi

	# CI failure fix-up: when required checks fail on a worker/trusted PR with a
	# linked issue, collect check details, append to issue body, close the PR,
	# and set the issue to status:available for re-dispatch.
	# The next worker sees the CI failure context and can fix it. t2189:
	# idle interactive PRs are handed over via origin:worker-takeover and
	# then routed through the same pipeline — human session must be gone
	# (no status, no claim stamp, >24h idle) for handover to fire.
	[[ -n "$timing_prefix" ]] && _branch_protection_start=$(_pmp_now_epoch)
	if ! _pr_required_checks_pass "$pr_number" "$repo_slug"; then
		# t2922: For origin:worker PRs, phantom-pending non-required checks
		# (CodeRabbit, qlty, linked-issue-check, url-allowlist, etc.) can
		# report null status indefinitely and cause _pr_required_checks_pass
		# to fail-closed via an API quirk. Cross-check with the branch
		# protection API (authoritative required-context list). If every
		# required-by-protection context is passing, bypass this block and
		# let the worker-briefed trust-chain gates run. Non-worker PRs
		# (external contributors, interactive sessions) take the normal
		# CI-failure routing path, preserving the contributor security gate.
		local _rcl_labels
		_rcl_labels="$pr_labels"
		if _is_trusted_dependabot_update_pr "$pr_number" "$repo_slug" "$pr_author" \
			&& _trusted_dependabot_non_review_checks_green "$pr_number" "$repo_slug" "$pr_obj"; then
			echo "[pulse-merge] PR #${pr_number} in ${repo_slug}: _pr_required_checks_pass bypassed for trusted Dependabot — all non-review-bot checks are green (GH#24477)" >>"$LOGFILE"
		elif [[ -n "${_OW_LABEL_PAT:-}" && ",${_rcl_labels}," == *"${_OW_LABEL_PAT:-}"* ]] \
			&& _check_required_checks_passing "$repo_slug" "$pr_number"; then
			echo "[pulse-merge] PR #${pr_number} in ${repo_slug}: _pr_required_checks_pass bypassed for origin:worker — branch-protection required contexts all pass (t2922)" >>"$LOGFILE"
			# Fall through to linked-issue fetch and merge gate checks
		else
			# t2805: try cheap rebase first if PR is behind base — pre-existing
			# failures in unrelated tests are often fixed by base advancement.
			# If rebase succeeds, skip fix-worker routing — next pulse cycle
			# will re-check CI on the rebased HEAD.
			if _attempt_pr_ci_rebase_retry "$pr_number" "$repo_slug" "$pr_base_ref_name" "$pr_head_ref_oid"; then
				[[ -n "$timing_prefix" ]] && _pmp_add_elapsed_seconds "${timing_prefix}branch_protection_s" "$_branch_protection_start"
				return 1
			fi
			# CI failure: route to fix worker if applicable (t2203: consolidated).
			_route_pr_to_fix_worker "$pr_number" "$repo_slug" "$linked_issue" "ci" "$pr_labels" "" "$pr_updated_at" "$pr_head_ref_oid" || true
			[[ -n "$timing_prefix" ]] && _pmp_add_elapsed_seconds "${timing_prefix}branch_protection_s" "$_branch_protection_start"
			return 1
		fi
	fi
	[[ -n "$timing_prefix" ]] && _pmp_add_elapsed_seconds "${timing_prefix}branch_protection_s" "$_branch_protection_start"

	# Dry-run must stop before every write side effect. The standalone merge
	# routine advertises DRY_RUN=1 as no-side-effects, but the historical path
	# continued into auto-approval, native auto-merge, admin merge, closing
	# comments, and issue closure. Keep all read-only eligibility checks above
	# so dry-run still proves whether the PR would pass gates, then return before
	# any GitHub write.
	if [[ "${DRY_RUN:-0}" == "1" ]]; then
		echo "[pulse-wrapper] DRY-RUN: would merge PR #${pr_number} in ${repo_slug} (linked_issue=#${linked_issue:-none})" >>"$LOGFILE"
		return 0
	fi

	# GH#24839/GH#24840: existing native auto-merge requests can wedge when
	# GitHub reports MERGEABLE+BEHIND with all required checks green. Update the
	# branch before the t3070 native-auto defer path, otherwise pulse keeps
	# waiting for GitHub to do work that GitHub requires a branch update for.
	if _attempt_existing_auto_merge_behind_update_branch "$pr_number" "$repo_slug"; then
		return 1
	fi
	# GH#26659: repos with native auto-merge disabled still need green+BEHIND
	# branches updated before admin/direct merge attempts, otherwise rulesets and
	# branch-protection "head branch is not up to date" errors can loop forever.
	if _attempt_green_behind_update_branch "$pr_number" "$repo_slug"; then
		return 1
	fi

	# Approve (satisfies REVIEW_REQUIRED for collaborator PRs)
	approve_collaborator_pr "$pr_number" "$repo_slug" "$pr_author" 2>/dev/null || true
	[[ -n "$timing_prefix" ]] && _ruleset_start=$(_pmp_now_epoch)
	if ! _check_ruleset_required_reviews_passing "$repo_slug" "$pr_number" "$pr_author"; then
		[[ -n "$timing_prefix" ]] && _pmp_add_elapsed_seconds "${timing_prefix}ruleset_s" "$_ruleset_start"
		return 1
	fi
	[[ -n "$timing_prefix" ]] && _pmp_add_elapsed_seconds "${timing_prefix}ruleset_s" "$_ruleset_start"

	# Extract merge summary: MERGE_SUMMARY comment → PR body → generic fallback
	local merge_summary
	merge_summary=$(_extract_merge_summary "$pr_number" "$repo_slug")

	# Retarget any open PRs stacked on this branch before --delete-branch
	# kills their base. GitHub auto-closes children without warning when their
	# base branch disappears; retargeting to the default branch prevents this.
	# (t2412 / GH#20005)
	_retarget_stacked_children "$pr_number" "$repo_slug" "$pr_head_ref_name"

	# Defense-in-depth (t2934). Refuse `--admin` merge for external/fork PRs
	# without crypto approval, evaluated at the bypass call site so that any
	# future regression in upstream gate ordering, label-application timing,
	# or new code paths cannot re-open the threat addressed by PR #17868
	# (the 2026-04-07 incident: #17671, #17685, #3846 merged via Check 0
	# bypass). Returns 1 (skipped) — same semantics as a gate failure above.
	if ! _pulse_merge_admin_safety_check "$pr_number" "$repo_slug"; then
		return 1
	fi

	# Native auto-merge fast-track (t3070): if CI is still pending and the
	# repo has allow_auto_merge enabled, hand the merge over to GitHub —
	# it merges within seconds of green instead of waiting for the next
	# pulse poll cycle (~120s). Falls through to --admin immediate merge
	# when CI is already green, repo opts out, or the API call fails.
	local _native_auto_rc=0
	_set_native_auto_merge_or_skip "$pr_number" "$repo_slug" || _native_auto_rc=$?
	case "$_native_auto_rc" in
		0)
			return 4
			;;
	esac

	# Merge. Prefer the historical admin path for owned repos, but fall back to
	# protection-respecting merge paths when repository rulesets reject admin
	# bypass. GitHub reports ruleset blocks as a generic GraphQL error; retrying
	# the same --admin call every pulse cycle creates a zero-progress loop even
	# when the PR is otherwise green. First ask GitHub to enqueue/auto-merge the
	# PR without admin bypass, then fall back to a direct non-admin merge for repos
	# whose rulesets allow immediate maintainer merges.
	local merge_output="" _merge_exit=0 _auto_merge_output="" _auto_merge_exit=0
	local merge_failure_context=""
	merge_output=$(gh pr merge "$pr_number" --repo "$repo_slug" --squash --admin 2>&1)
	_merge_exit=$?
	if [[ $_merge_exit -ne 0 ]] && gh_merge_remediate_stale_auth_cache "$merge_output" "pulse merge PR #${pr_number} in ${repo_slug}" "$LOGFILE"; then
		local _merge_original_output="$merge_output"
		merge_output=$(gh pr merge "$pr_number" --repo "$repo_slug" --squash --admin 2>&1)
		_merge_exit=$?
		if [[ $_merge_exit -ne 0 ]]; then
			merge_output="${_merge_original_output}

[retry after stale gh cache remediation]
${merge_output}"
		fi
	fi
	if [[ $_merge_exit -ne 0 ]]; then
		merge_failure_context="[admin merge]
${merge_output}"
	fi
	if [[ $_merge_exit -ne 0 && "$merge_output" == *"Repository rule violations found"* ]]; then
		echo "[pulse-wrapper] Deterministic merge: admin merge hit repository rulesets for PR #${pr_number} in ${repo_slug}; retrying with native auto-merge without --admin (GH#24438): ${merge_output}" >>"$LOGFILE"
		_auto_merge_output=$(gh pr merge "$pr_number" --repo "$repo_slug" --auto --squash 2>&1)
		_auto_merge_exit=$?
		if [[ $_auto_merge_exit -eq 0 ]]; then
			echo "[pulse-wrapper] Deterministic merge: enabled native auto-merge for PR #${pr_number} in ${repo_slug} after ruleset blocked admin bypass (GH#24438)" >>"$LOGFILE"
			return 0
		fi
		merge_failure_context="${merge_failure_context}

[native auto-merge fallback]
${_auto_merge_output}"
		echo "[pulse-wrapper] Deterministic merge: native auto-merge fallback failed for PR #${pr_number} in ${repo_slug}; retrying direct merge without --admin (GH#23087): ${_auto_merge_output}" >>"$LOGFILE"
		merge_output=$(gh pr merge "$pr_number" --repo "$repo_slug" --squash 2>&1)
		_merge_exit=$?
		if [[ $_merge_exit -ne 0 ]] && gh_merge_remediate_stale_auth_cache "$merge_output" "pulse direct merge PR #${pr_number} in ${repo_slug}" "$LOGFILE"; then
			local _direct_merge_original_output="$merge_output"
			merge_output=$(gh pr merge "$pr_number" --repo "$repo_slug" --squash 2>&1)
			_merge_exit=$?
			if [[ $_merge_exit -ne 0 ]]; then
				merge_output="${_direct_merge_original_output}

[retry after stale gh cache remediation]
${merge_output}"
			fi
		fi
		if [[ $_merge_exit -ne 0 ]]; then
			merge_failure_context="${merge_failure_context}

[direct merge fallback]
${merge_output}"
		fi
	fi

	# Rate-limit: 1 second between merges to avoid GitHub API abuse
	sleep 1

	if [[ $_merge_exit -eq 0 ]]; then
		echo "[pulse-wrapper] Deterministic merge: merged PR #${pr_number} in ${repo_slug}" >>"$LOGFILE"
		# t2411: emit audit log for origin:interactive auto-merges
		local _ipr_labels="$pr_labels"
		if [[ "$_ipr_labels" == *"origin:interactive"* ]]; then
			local _ipr_role="collaborator"
			_is_owner_or_member_author "$pr_author" "$repo_slug" \
				&& _ipr_role="owner-or-member" || true
			echo "[pulse-merge] auto-merged origin:interactive PR #${pr_number} (author=${pr_author}, role=${_ipr_role})" >>"$LOGFILE"
		fi
		# t2449: emit audit log for origin:worker worker-briefed auto-merges
		if [[ -n "${_OW_LABEL_PAT:-}" && ",${_ipr_labels}," == *"${_OW_LABEL_PAT:-}"* ]]; then
			echo "[pulse-merge] auto-merged origin:worker (worker-briefed) PR #${pr_number} (author=${pr_author}, linked_issue=#${linked_issue:-unknown})" >>"$LOGFILE"
		fi
		_handle_post_merge_actions "$pr_number" "$repo_slug" "$linked_issue" "$merge_summary" "$_ipr_labels" "$pr_base_ref_name"
		return 0
	elif [[ "$merge_output" == *"Merge already in progress"* ]]; then
		echo "[pulse-wrapper] Deterministic merge: PR #${pr_number} in ${repo_slug} already has a merge in progress; counting as merge progress (GH#24383): ${merge_output}" >>"$LOGFILE"
		local _ipr_labels="$pr_labels"
		_handle_post_merge_actions "$pr_number" "$repo_slug" "$linked_issue" "$merge_summary" "$_ipr_labels" "$pr_base_ref_name"
		return $?
	else
		local final_merge_output="$merge_output"
		[[ -n "$merge_failure_context" ]] && final_merge_output="$merge_failure_context"
		echo "[pulse-wrapper] Deterministic merge: FAILED PR #${pr_number} in ${repo_slug}: ${final_merge_output}" >>"$LOGFILE"
		_pulse_merge_maybe_dispatch_review_thread_remediation "$pr_number" "$repo_slug" "$final_merge_output"
		return 3
	fi
}

#######################################
# Process a single PR by (slug, pr_number) tuple. Webhook entry point (t3038).
#
# Fetches the PR JSON for the given (slug, pr_number) and delegates to
# _process_single_ready_pr. Used by pulse-merge-webhook-receiver.sh to
# fire merge attempts immediately on GitHub webhook events
# (check_suite.completed, pull_request_review.submitted, pull_request.labeled)
# instead of waiting for the next pulse-merge-routine cycle.
#
# The 120s polling loop in pulse-merge-routine.sh remains as backstop —
# webhook-driven merges are an optimization, not a replacement.
#
# Args:
#   $1 - repo slug (owner/repo)
#   $2 - PR number
# Returns:
#   0 = merged successfully
#   1 = skipped (gate failure, non-mergeable, or PR not found)
#   2 = closed conflicting
#   3 = merge failed
#   4 = native auto-merge requested/deferred; no merge completed this cycle
#######################################
process_pr() {
	local repo_slug="$1"
	local pr_number="$2"

	if [[ -z "$repo_slug" || -z "$pr_number" ]]; then
		echo "[pulse-merge] process_pr: missing slug or PR number (slug='${repo_slug}', pr='${pr_number}')" >>"$LOGFILE"
		return 1
	fi
	if ! [[ "$pr_number" =~ ^[0-9]+$ ]]; then
		echo "[pulse-merge] process_pr: invalid PR number '${pr_number}' for ${repo_slug}" >>"$LOGFILE"
		return 1
	fi

	# Fetch the PR JSON in the same shape _merge_ready_prs_for_repo uses and
	# synthesize a single-PR object. _process_single_ready_pr expects a compact
	# JSON object with metadata used by draft, label, stale, and repair-routing
	# gates.
	local pr_obj
	pr_obj=$(gh_pr_view "$pr_number" --repo "$repo_slug" \
		--json "$(_pulse_merge_ready_pr_json_fields)" 2>/dev/null) || pr_obj=""

	if [[ -z "$pr_obj" || "$pr_obj" == "null" ]]; then
		echo "[pulse-merge] process_pr: gh pr view failed for ${repo_slug}#${pr_number}" >>"$LOGFILE"
		return 1
	fi

	# Verify state is OPEN — closed/merged PRs should not be re-processed.
	local pr_state
	pr_state=$(gh_pr_view "$pr_number" --repo "$repo_slug" \
		--json state --jq '.state // ""' 2>/dev/null) || pr_state=""
	if [[ "$pr_state" != "OPEN" ]]; then
		echo "[pulse-merge] process_pr: PR ${repo_slug}#${pr_number} is not OPEN (state=${pr_state}) — skipping" >>"$LOGFILE"
		return 1
	fi

	echo "[pulse-merge] process_pr: webhook-triggered merge attempt for ${repo_slug}#${pr_number} (t3038)" >>"$LOGFILE"
	_process_single_ready_pr "$repo_slug" "$pr_obj"
	return $?
}

#######################################
# Extract linked issue number from PR title or body.
# Looks for: GitHub-native close keywords in PR body, "GH#NNN:" prefix in title.
#
# Close keyword matching (GH#18098): only GitHub-native keywords trigger auto-close —
# bare GH#NNN references in "Related" sections do NOT.  GitHub's full keyword list:
# close, closes, closed, fix, fixes, fixed, resolve, resolves, resolved (case-insensitive).
# GH#NNN matching is restricted to the PR title to avoid treating informational body
# references as closing keywords.
#
# Args: $1=PR number, $2=repo slug
# Returns: issue number on stdout, or empty if none found
#######################################
_extract_linked_issue() {
	local pr_number="$1"
	local repo_slug="$2"
	local pr_title pr_body
	pr_title=$(gh_pr_view "$pr_number" --repo "$repo_slug" --json title --jq '.title // empty' 2>/dev/null) || pr_title=""
	pr_body=$(gh_pr_view "$pr_number" --repo "$repo_slug" --json body --jq '.body // empty' 2>/dev/null) || pr_body=""

	# Match GitHub-native close keywords in the PR body only (case-insensitive).
	# Matches: close/closes/closed, fix/fixes/fixed, resolve/resolves/resolved.
	# Does NOT match bare GH#NNN, "Related #NNN", "For #NNN", "Ref #NNN", or other
	# non-closing references. (GH#18098 + t2108)
	#
	# The body keyword is AUTHORITATIVE. The title fallback below only fires when
	# the body has a closing keyword AND the title also names a number — it picks
	# WHICH issue from the body matches when there are multiple. It is NEVER an
	# override that creates a match where the body intentionally has none. (t2108)
	local body_issue title_issue
	body_issue=$(printf '%s' "$pr_body" | grep -ioE '(close[ds]?|fix(es|ed)?|resolve[ds]?)\s+#[0-9]+' | head -1 | grep -oE '[0-9]+')
	title_issue=$(printf '%s' "$pr_title" | grep -oE 'GH#[0-9]+' | head -1 | grep -oE '[0-9]+')

	# No closing keyword in the body → return empty. The PR is intentionally
	# not closing any issue (planning-only PR, multi-PR roadmap, "For #NNN"
	# reference, etc.). _handle_post_merge_actions will skip the close path
	# when this returns empty. (t2108)
	if [[ -z "$body_issue" ]]; then
		return 0
	fi

	# Body has a closing keyword. If the title also names a number, prefer the
	# title-named issue when it differs from body_issue (matches the historical
	# behaviour where the GH#NNN: title prefix is the primary identifier and
	# the body may reference additional issues). When they match or the title
	# has no number, return body_issue. (t2108)
	if [[ -n "$title_issue" ]]; then
		printf '%s' "$title_issue"
		return 0
	fi
	printf '%s' "$body_issue"
	return 0
}

#######################################
# Extract the worker's merge summary from PR comments.
#
# Workers post a structured comment tagged with <!-- MERGE_SUMMARY -->
# on the PR at creation time (full-loop.md step 4.2.1). This function
# finds the most recent such comment and returns its body (without the
# HTML tag) for use in closing comments.
#
# Args: $1=PR number, $2=repo slug
# Output: merge summary text on stdout (empty if none found)
#######################################
_extract_merge_summary() {
	local pr_number="$1"
	local repo_slug="$2"

	# Strategy 1: Look for explicit MERGE_SUMMARY tagged comment (richest content)
	local summary
	summary=$(gh api "repos/${repo_slug}/issues/${pr_number}/comments" \
		--jq '[.[] | select(.body | test("<!-- MERGE_SUMMARY -->"))] | last | .body // empty' \
		2>/dev/null) || summary=""

	if [[ -n "$summary" ]]; then
		# Strip the HTML marker tag
		summary=$(printf '%s' "$summary" | sed 's/<!-- MERGE_SUMMARY -->//')
		# Strip the worker's "written at PR creation time" note if present
		summary=$(printf '%s' "$summary" | sed '/written by the worker at PR creation time/d')
		printf '%s' "$summary"
		return 0
	fi

	# Strategy 2: Extract from PR body (always present, created atomically with PR).
	# Workers skip the MERGE_SUMMARY comment ~65% of the time, but the PR body
	# always contains a useful description of what was done (GH#17503).
	local pr_body
	pr_body=$(gh_pr_view "$pr_number" --repo "$repo_slug" \
		--json body --jq '.body // empty' 2>/dev/null) || pr_body=""

	if [[ -z "$pr_body" ]]; then
		return 0
	fi

	# Strip auto-generated bot content (CodeRabbit, SonarCloud, Codacy, etc.)
	# These start with <!-- This is an auto-generated comment or similar markers
	pr_body=$(printf '%s\n' "$pr_body" | sed '/<!-- This is an auto-generated comment/,$d')

	# Strip Closes/Fixes/Resolves #NNN (the closing comment adds its own PR reference)
	pr_body=$(printf '%s\n' "$pr_body" | sed -E 's/(Closes|Fixes|Resolves) #[0-9]+[[:space:]]*//')

	# Trim leading/trailing blank lines (BSD sed compatible)
	pr_body=$(printf '%s\n' "$pr_body" | sed '/./,$!d' | sed -E '/^[[:space:]]*$/{ N; }' | sed -E '/^[[:space:]]*$/d')

	# Only use if there's meaningful content left (more than just whitespace)
	if [[ -n "$pr_body" ]] && [[ "$(printf '%s' "$pr_body" | tr -d '[:space:]')" != "" ]]; then
		printf '%s' "$pr_body"
	fi

	return 0
}
