Add assess_non_mergeable_skip_proof to validate reviewer reports document head SHA, mergeability result, conflict proof command, conflicting files, and head-change status for each skipped non-mergeable PR, or classify MERGEABILITY_UNVERIFIED when proof cannot be retrieved. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
159 lines
5.8 KiB
Python
159 lines
5.8 KiB
Python
"""Non-mergeable PR skip conflict-proof verifier for reviewer reports (#322)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
_FULL_SHA = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
|
|
_SHORT_SHA = re.compile(r"\b[0-9a-f]{7,39}\b", re.IGNORECASE)
|
|
_PR_NUMBER_RE = re.compile(r"(?:\bPR\s*#?|#)(\d+)\b", re.IGNORECASE)
|
|
_MERGEABILITY_FALSE_RE = re.compile(
|
|
r"(?:mergeable\s*:\s*false|not mergeable|non[- ]mergeable|mergeability\s*:\s*false|"
|
|
r"mergeability result\s*:\s*false)",
|
|
re.IGNORECASE,
|
|
)
|
|
_CONFLICT_PROOF_RE = re.compile(
|
|
r"(?:merge-tree|git merge-tree|gitea_view_pr|gitea_check_pr_eligibility|"
|
|
r"conflict proof|mergeability tool|merge simulation|conflicting files?)",
|
|
re.IGNORECASE,
|
|
)
|
|
_CONFLICTING_FILES_RE = re.compile(
|
|
r"(?:conflicting files?|conflict files?)\s*:\s*(.+)$",
|
|
re.IGNORECASE | re.MULTILINE,
|
|
)
|
|
_HEAD_CHANGED_RE = re.compile(
|
|
r"(?:head (?:changed|unchanged)|head sha (?:changed|unchanged)|"
|
|
r"head changed since|head unchanged since)",
|
|
re.IGNORECASE,
|
|
)
|
|
_MERGEABILITY_UNVERIFIED_RE = re.compile(
|
|
r"\bMERGEABILITY_UNVERIFIED\b",
|
|
)
|
|
|
|
|
|
def _pr_section(text: str, pr_number: int) -> str:
|
|
"""Return report lines likely describing one skipped PR."""
|
|
lines = (text or "").splitlines()
|
|
chunks: list[str] = []
|
|
capture = False
|
|
token = f"#{pr_number}"
|
|
for line in lines:
|
|
lower = line.lower()
|
|
if token in lower or f"pr {pr_number}" in lower or f"pr#{pr_number}" in lower.replace(" ", ""):
|
|
capture = True
|
|
chunks.append(line)
|
|
continue
|
|
if capture:
|
|
if _PR_NUMBER_RE.search(line) and token not in line.lower():
|
|
break
|
|
if line.strip() == "" and len(chunks) > 3:
|
|
break
|
|
chunks.append(line)
|
|
if chunks:
|
|
return "\n".join(chunks)
|
|
return text or ""
|
|
|
|
|
|
def _has_head_sha(text: str, head_sha: str | None) -> bool:
|
|
if not head_sha:
|
|
return bool(_FULL_SHA.search(text) or _SHORT_SHA.search(text))
|
|
head = head_sha.strip().lower()
|
|
if head in text.lower():
|
|
return True
|
|
if len(head) >= 7 and head[:7] in text.lower():
|
|
return True
|
|
return bool(_FULL_SHA.search(text))
|
|
|
|
|
|
def assess_non_mergeable_skip_proof(
|
|
report_text: str,
|
|
*,
|
|
skipped_prs: list[dict] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Validate skipped non-mergeable PR documentation in reviewer reports (#322)."""
|
|
text = report_text or ""
|
|
reasons: list[str] = []
|
|
assessments: list[dict[str, Any]] = []
|
|
|
|
for entry in skipped_prs or []:
|
|
pr_number = entry.get("pr_number")
|
|
if pr_number is None:
|
|
reasons.append("skipped PR entry missing pr_number")
|
|
continue
|
|
pr_number = int(pr_number)
|
|
section = _pr_section(text, pr_number)
|
|
mergeable = entry.get("mergeable")
|
|
verified = entry.get("mergeability_verified")
|
|
classification = (entry.get("classification") or "").strip().upper()
|
|
head_sha = (entry.get("head_sha") or "").strip() or None
|
|
|
|
item_reasons: list[str] = []
|
|
|
|
if f"#{pr_number}" not in text.lower() and f"pr {pr_number}" not in text.lower():
|
|
item_reasons.append(f"skipped PR #{pr_number} not documented in final report")
|
|
|
|
if mergeable is False or verified is False:
|
|
if not _MERGEABILITY_UNVERIFIED_RE.search(section) and verified is False:
|
|
if "MERGEABILITY_UNVERIFIED" not in classification:
|
|
item_reasons.append(
|
|
f"PR #{pr_number} conflict proof unavailable; report must classify "
|
|
"MERGEABILITY_UNVERIFIED"
|
|
)
|
|
elif verified is not False:
|
|
if not _MERGEABILITY_FALSE_RE.search(section):
|
|
item_reasons.append(
|
|
f"PR #{pr_number} skip missing mergeability:false proof"
|
|
)
|
|
if not _has_head_sha(section, head_sha):
|
|
item_reasons.append(
|
|
f"PR #{pr_number} skip missing current head SHA"
|
|
)
|
|
if not _CONFLICT_PROOF_RE.search(section):
|
|
item_reasons.append(
|
|
f"PR #{pr_number} skip missing conflict proof command/tool"
|
|
)
|
|
if entry.get("head_changed_since_prior_blocker") is not None:
|
|
if not _HEAD_CHANGED_RE.search(section):
|
|
item_reasons.append(
|
|
f"PR #{pr_number} skip missing head-changed-since-blocker proof"
|
|
)
|
|
|
|
if (
|
|
entry.get("conflicting_files")
|
|
and not _CONFLICTING_FILES_RE.search(section)
|
|
and not any(
|
|
path.lower() in section.lower()
|
|
for path in (entry.get("conflicting_files") or [])
|
|
)
|
|
):
|
|
item_reasons.append(
|
|
f"PR #{pr_number} has conflicting files in session proof but "
|
|
"report omits file-level conflict proof"
|
|
)
|
|
|
|
proven = not item_reasons
|
|
assessments.append({
|
|
"pr_number": pr_number,
|
|
"proven": proven,
|
|
"classification": classification or (
|
|
"MERGEABILITY_UNVERIFIED" if verified is False else "NON_MERGEABLE_SKIPPED"
|
|
),
|
|
"reasons": item_reasons,
|
|
})
|
|
reasons.extend(item_reasons)
|
|
|
|
proven = not reasons
|
|
return {
|
|
"proven": proven,
|
|
"block": not proven,
|
|
"downgraded": False,
|
|
"assessments": assessments,
|
|
"reasons": reasons,
|
|
"safe_next_action": (
|
|
"document each skipped non-mergeable PR with head SHA, mergeability result, "
|
|
"conflict proof command, conflicting files, and head-change status"
|
|
if reasons
|
|
else "proceed"
|
|
),
|
|
} |