Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eac7afe5cb | ||
|
|
fdab6b6c69 | ||
|
|
277ec5269d | ||
|
|
b05075fd25 |
+200
-52
@@ -12132,6 +12132,13 @@ def gitea_heartbeat_reviewer_pr_lease(
|
|||||||
verify_preflight_purity(remote, task="review_pr")
|
verify_preflight_purity(remote, task="review_pr")
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
|
# Slide the sliding TTL forward (#747): the heartbeat is the liveness proof,
|
||||||
|
# so renewal is stated explicitly rather than inherited from the acquisition
|
||||||
|
# default.
|
||||||
|
beat_at = datetime.now(timezone.utc)
|
||||||
|
renewed_expiry = beat_at + timedelta(
|
||||||
|
minutes=reviewer_pr_lease.LEASE_RENEWAL_MINUTES
|
||||||
|
)
|
||||||
body = reviewer_pr_lease.format_lease_body(
|
body = reviewer_pr_lease.format_lease_body(
|
||||||
repo=f"{o}/{r}",
|
repo=f"{o}/{r}",
|
||||||
pr_number=pr_number,
|
pr_number=pr_number,
|
||||||
@@ -12144,6 +12151,8 @@ def gitea_heartbeat_reviewer_pr_lease(
|
|||||||
candidate_head=candidate_head or session.get("candidate_head"),
|
candidate_head=candidate_head or session.get("candidate_head"),
|
||||||
target_branch=session.get("target_branch") or "master",
|
target_branch=session.get("target_branch") or "master",
|
||||||
target_branch_sha=target_branch_sha or session.get("target_branch_sha"),
|
target_branch_sha=target_branch_sha or session.get("target_branch_sha"),
|
||||||
|
last_activity=beat_at,
|
||||||
|
ttl_minutes=reviewer_pr_lease.LEASE_RENEWAL_MINUTES,
|
||||||
)
|
)
|
||||||
comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||||||
with _audited(
|
with _audited(
|
||||||
@@ -12184,6 +12193,13 @@ def gitea_heartbeat_reviewer_pr_lease(
|
|||||||
"phase": phase,
|
"phase": phase,
|
||||||
"comment_id": posted.get("id"),
|
"comment_id": posted.get("id"),
|
||||||
"session_lease": updated,
|
"session_lease": updated,
|
||||||
|
# Report the renewed window so an operator can tell "held and live"
|
||||||
|
# from "held and dying" (#747).
|
||||||
|
"ttl_minutes": reviewer_pr_lease.LEASE_RENEWAL_MINUTES,
|
||||||
|
"expires_at": renewed_expiry.replace(microsecond=0)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z"),
|
||||||
|
"seconds_remaining": reviewer_pr_lease.LEASE_RENEWAL_MINUTES * 60,
|
||||||
"reasons": [],
|
"reasons": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15351,46 +15367,151 @@ def _count_commits_behind(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_protection_rule(protections: Any, branch: str) -> dict | None:
|
||||||
|
"""Select the branch-protection rule governing ``branch`` (#751)."""
|
||||||
|
if not isinstance(protections, list):
|
||||||
|
return None
|
||||||
|
for rule in protections:
|
||||||
|
if not isinstance(rule, dict):
|
||||||
|
continue
|
||||||
|
name = (rule.get("branch_name") or rule.get("rule_name") or "").strip()
|
||||||
|
# Exact match or glob-ish contains for common patterns.
|
||||||
|
if name == branch or name in ("*", f"{branch}"):
|
||||||
|
return rule
|
||||||
|
# Fallback: any protection that mentions the base branch.
|
||||||
|
for rule in protections:
|
||||||
|
if not isinstance(rule, dict):
|
||||||
|
continue
|
||||||
|
name = (rule.get("branch_name") or rule.get("rule_name") or "").strip()
|
||||||
|
if name and (branch in name or name.endswith(branch)):
|
||||||
|
return rule
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _branch_protection_policy(
|
||||||
|
base_url: str,
|
||||||
|
auth: dict,
|
||||||
|
*,
|
||||||
|
base_branch: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Read the live branch-protection policy for ``base_branch`` (#751).
|
||||||
|
|
||||||
|
Exposes both the current-base rule (``block_on_outdated_branch``) and the
|
||||||
|
status-check requirement (``enable_status_check`` / ``status_check_contexts``)
|
||||||
|
from the same payload, so the checks assessor can tell "no checks required"
|
||||||
|
apart from "required checks pending".
|
||||||
|
|
||||||
|
``determinable`` is False only when the policy genuinely could not be read
|
||||||
|
(missing branch or API failure) — never merely because no rule exists. A
|
||||||
|
successful read that finds no protection for the branch is authoritative
|
||||||
|
evidence that status checks are not required.
|
||||||
|
"""
|
||||||
|
policy: dict[str, Any] = {
|
||||||
|
"determinable": False,
|
||||||
|
"protection_found": False,
|
||||||
|
"requires_current_base": None,
|
||||||
|
"checks_enabled": None,
|
||||||
|
"required_contexts": [],
|
||||||
|
"base_branch": (base_branch or "").strip() or None,
|
||||||
|
}
|
||||||
|
branch = (base_branch or "").strip()
|
||||||
|
if not branch:
|
||||||
|
return policy
|
||||||
|
|
||||||
|
def _apply(rule: dict) -> None:
|
||||||
|
policy["protection_found"] = True
|
||||||
|
if "block_on_outdated_branch" in rule:
|
||||||
|
policy["requires_current_base"] = bool(
|
||||||
|
rule.get("block_on_outdated_branch")
|
||||||
|
)
|
||||||
|
if "enable_status_check" in rule:
|
||||||
|
policy["checks_enabled"] = bool(rule.get("enable_status_check"))
|
||||||
|
contexts = rule.get("status_check_contexts")
|
||||||
|
if isinstance(contexts, list):
|
||||||
|
policy["required_contexts"] = [
|
||||||
|
str(ctx).strip() for ctx in contexts if str(ctx or "").strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
protections = api_request(
|
||||||
|
"GET", f"{base_url}/branch_protections", auth
|
||||||
|
)
|
||||||
|
rule = _matching_protection_rule(protections, branch)
|
||||||
|
if rule is not None:
|
||||||
|
_apply(rule)
|
||||||
|
if not policy["protection_found"]:
|
||||||
|
# Branch payload may embed effective protection.
|
||||||
|
br = api_request("GET", f"{base_url}/branches/{branch}", auth) or {}
|
||||||
|
prot = br.get("protection") or br.get("effective_branch_protection") or {}
|
||||||
|
if isinstance(prot, dict) and prot:
|
||||||
|
_apply(prot)
|
||||||
|
policy["determinable"] = True
|
||||||
|
except Exception:
|
||||||
|
# Genuine read failure — leave determinable False so callers fail closed.
|
||||||
|
return policy
|
||||||
|
|
||||||
|
if not policy["protection_found"]:
|
||||||
|
# Authoritative absence: no protection governs the branch, so no status
|
||||||
|
# check is required by policy.
|
||||||
|
policy["checks_enabled"] = False
|
||||||
|
elif policy["checks_enabled"] is None:
|
||||||
|
# Protection exists but omits the status-check field entirely: Gitea
|
||||||
|
# only enforces contexts when the flag is set, so absence means off.
|
||||||
|
policy["checks_enabled"] = False
|
||||||
|
|
||||||
|
return policy
|
||||||
|
|
||||||
|
|
||||||
def _branch_protection_requires_current_base(
|
def _branch_protection_requires_current_base(
|
||||||
base_url: str,
|
base_url: str,
|
||||||
auth: dict,
|
auth: dict,
|
||||||
*,
|
*,
|
||||||
base_branch: str,
|
base_branch: str,
|
||||||
) -> bool | None:
|
) -> bool | None:
|
||||||
"""Read Gitea branch protection ``block_on_outdated_branch`` when present."""
|
"""Read Gitea branch protection ``block_on_outdated_branch`` when present.
|
||||||
branch = (base_branch or "").strip()
|
|
||||||
if not branch:
|
Thin accessor over :func:`_branch_protection_policy`; return semantics are
|
||||||
return None
|
unchanged (``None`` when the rule is absent or unreadable).
|
||||||
|
"""
|
||||||
|
policy = _branch_protection_policy(base_url, auth, base_branch=base_branch)
|
||||||
|
return policy.get("requires_current_base")
|
||||||
|
|
||||||
|
|
||||||
|
def _commit_checks_snapshot(
|
||||||
|
base_url: str,
|
||||||
|
auth: dict,
|
||||||
|
*,
|
||||||
|
sha: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Read the combined commit status *and its context collection* (#751).
|
||||||
|
|
||||||
|
The combined ``state`` alone is not decisive: Gitea reports ``pending`` for
|
||||||
|
a commit with an empty status-context collection, which is indistinguishable
|
||||||
|
from executing CI unless the collection itself is inspected.
|
||||||
|
"""
|
||||||
|
snapshot: dict[str, Any] = {
|
||||||
|
"determinable": False,
|
||||||
|
"combined_state": None,
|
||||||
|
"statuses": [],
|
||||||
|
}
|
||||||
|
head = (sha or "").strip()
|
||||||
|
if not head:
|
||||||
|
return snapshot
|
||||||
try:
|
try:
|
||||||
# Prefer the named protection rule matching the base branch.
|
payload = api_request(
|
||||||
protections = api_request(
|
"GET", f"{base_url}/commits/{head}/status", auth
|
||||||
"GET", f"{base_url}/branch_protections", auth
|
)
|
||||||
) or []
|
|
||||||
if isinstance(protections, list):
|
|
||||||
for rule in protections:
|
|
||||||
if not isinstance(rule, dict):
|
|
||||||
continue
|
|
||||||
name = (rule.get("branch_name") or rule.get("rule_name") or "").strip()
|
|
||||||
# Exact match or glob-ish contains for common patterns.
|
|
||||||
if name == branch or name in ("*", f"{branch}"):
|
|
||||||
if "block_on_outdated_branch" in rule:
|
|
||||||
return bool(rule.get("block_on_outdated_branch"))
|
|
||||||
# Fallback: any protection that mentions the base branch.
|
|
||||||
for rule in protections:
|
|
||||||
if not isinstance(rule, dict):
|
|
||||||
continue
|
|
||||||
name = (rule.get("branch_name") or rule.get("rule_name") or "").strip()
|
|
||||||
if branch in name or name.endswith(branch):
|
|
||||||
if "block_on_outdated_branch" in rule:
|
|
||||||
return bool(rule.get("block_on_outdated_branch"))
|
|
||||||
# Branch payload may embed effective protection.
|
|
||||||
br = api_request("GET", f"{base_url}/branches/{branch}", auth) or {}
|
|
||||||
prot = br.get("protection") or br.get("effective_branch_protection") or {}
|
|
||||||
if isinstance(prot, dict) and "block_on_outdated_branch" in prot:
|
|
||||||
return bool(prot.get("block_on_outdated_branch"))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return snapshot
|
||||||
return None
|
if payload is None:
|
||||||
|
return snapshot
|
||||||
|
snapshot["determinable"] = True
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return snapshot
|
||||||
|
snapshot["combined_state"] = (payload.get("state") or "").strip().lower() or None
|
||||||
|
statuses = payload.get("statuses")
|
||||||
|
snapshot["statuses"] = statuses if isinstance(statuses, list) else []
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
def _prove_author_ownership_for_pr(
|
def _prove_author_ownership_for_pr(
|
||||||
@@ -15635,11 +15756,14 @@ def gitea_assess_pr_sync_status(
|
|||||||
# Fail closed on unknown behind-count only when SHAs differ.
|
# Fail closed on unknown behind-count only when SHAs differ.
|
||||||
commits_behind = 0 if pr_head_sha == base_head_sha else None
|
commits_behind = 0 if pr_head_sha == base_head_sha else None
|
||||||
|
|
||||||
|
# Single live read of the base-branch protection policy; it carries both
|
||||||
|
# the current-base rule and the status-check requirement (#751).
|
||||||
|
protection_policy = _branch_protection_policy(
|
||||||
|
base, auth, base_branch=base_branch
|
||||||
|
)
|
||||||
if branch_protection_requires_current_base is None:
|
if branch_protection_requires_current_base is None:
|
||||||
branch_protection_requires_current_base = (
|
branch_protection_requires_current_base = protection_policy.get(
|
||||||
_branch_protection_requires_current_base(
|
"requires_current_base"
|
||||||
base, auth, base_branch=base_branch
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
# When protection cannot be read, fail closed by requiring current base
|
# When protection cannot be read, fail closed by requiring current base
|
||||||
# whenever the PR is behind (safer for protected repos). Callers may pass
|
# whenever the PR is behind (safer for protected repos). Callers may pass
|
||||||
@@ -15706,23 +15830,30 @@ def gitea_assess_pr_sync_status(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# ── Live checks derivation (#751) ────────────────────────────────────
|
||||||
|
# Classify from the actual status-context collection plus the live
|
||||||
|
# protection policy. The combined ``state`` is never treated as proof that
|
||||||
|
# CI is executing, because Gitea reports ``pending`` for an empty
|
||||||
|
# collection. ``checks_required`` is always derived from live evidence and
|
||||||
|
# is deliberately not a caller-supplied input, so no session can declare
|
||||||
|
# checks optional without proof.
|
||||||
|
checks_snapshot = _commit_checks_snapshot(base, auth, sha=pr_head_sha or "")
|
||||||
|
checks_classification = pr_sync_status.classify_commit_checks(
|
||||||
|
combined_state=checks_snapshot.get("combined_state"),
|
||||||
|
statuses=checks_snapshot.get("statuses"),
|
||||||
|
checks_enabled=protection_policy.get("checks_enabled"),
|
||||||
|
required_contexts=protection_policy.get("required_contexts"),
|
||||||
|
policy_determinable=bool(protection_policy.get("determinable")),
|
||||||
|
status_determinable=bool(checks_snapshot.get("determinable")),
|
||||||
|
)
|
||||||
|
checks_required = bool(checks_classification.get("checks_required", True))
|
||||||
|
caller_supplied_checks_status = checks_status
|
||||||
if checks_status is None:
|
if checks_status is None:
|
||||||
checks_status = "unknown"
|
checks_status = checks_classification.get("checks_status")
|
||||||
# Combined status on PR head when Actions/status API is available.
|
elif checks_classification.get("checks_status") != pr_sync_status.CHECKS_UNKNOWN:
|
||||||
if pr_head_sha:
|
# Live evidence outranks a caller-supplied value; the override only
|
||||||
try:
|
# applies when live status could not be classified at all.
|
||||||
st = api_request(
|
checks_status = checks_classification.get("checks_status")
|
||||||
"GET",
|
|
||||||
f"{base}/commits/{pr_head_sha}/status",
|
|
||||||
auth,
|
|
||||||
) or {}
|
|
||||||
state = (st.get("state") or "").strip().lower()
|
|
||||||
if state:
|
|
||||||
checks_status = state
|
|
||||||
elif not st:
|
|
||||||
checks_status = "none"
|
|
||||||
except Exception:
|
|
||||||
checks_status = "unknown"
|
|
||||||
|
|
||||||
assessment = pr_sync_status.assess_pr_sync_status(
|
assessment = pr_sync_status.assess_pr_sync_status(
|
||||||
host=h,
|
host=h,
|
||||||
@@ -15743,9 +15874,26 @@ def gitea_assess_pr_sync_status(
|
|||||||
active_reviewer_lease=active_reviewer_lease,
|
active_reviewer_lease=active_reviewer_lease,
|
||||||
active_merger_lease=active_merger_lease,
|
active_merger_lease=active_merger_lease,
|
||||||
prepared_verdict_head_sha=prepared_verdict_head_sha,
|
prepared_verdict_head_sha=prepared_verdict_head_sha,
|
||||||
|
checks_required=checks_required,
|
||||||
)
|
)
|
||||||
assessment["remote"] = remote if remote in REMOTES else None
|
assessment["remote"] = remote if remote in REMOTES else None
|
||||||
assessment["base_branch"] = base_branch
|
assessment["base_branch"] = base_branch
|
||||||
|
# Evidence for the checks decision (#751) — no secrets, read-only.
|
||||||
|
assessment["checks_evidence"] = {
|
||||||
|
"combined_state": checks_classification.get("combined_state"),
|
||||||
|
"context_count": checks_classification.get("context_count"),
|
||||||
|
"observed_contexts": checks_classification.get("observed_contexts"),
|
||||||
|
"required_contexts": checks_classification.get("required_contexts"),
|
||||||
|
"missing_required_contexts": checks_classification.get(
|
||||||
|
"missing_required_contexts"
|
||||||
|
),
|
||||||
|
"policy_determinable": checks_classification.get("policy_determinable"),
|
||||||
|
"status_determinable": checks_classification.get("status_determinable"),
|
||||||
|
"protection_found": protection_policy.get("protection_found"),
|
||||||
|
"checks_enabled": protection_policy.get("checks_enabled"),
|
||||||
|
"caller_supplied_checks_status": caller_supplied_checks_status,
|
||||||
|
"reasons": checks_classification.get("reasons"),
|
||||||
|
}
|
||||||
assessment["success"] = True
|
assessment["success"] = True
|
||||||
assessment["performed"] = False
|
assessment["performed"] = False
|
||||||
return assessment
|
return assessment
|
||||||
|
|||||||
+213
-12
@@ -41,6 +41,186 @@ _DENIED_UPDATE_ROLES = frozenset({"reviewer", "merger", "reconciler", "mixed", "
|
|||||||
UPDATE_STYLE_MERGE = "merge"
|
UPDATE_STYLE_MERGE = "merge"
|
||||||
_FORBIDDEN_UPDATE_STYLES = frozenset({"rebase", "rebase-merge", "squash", "force"})
|
_FORBIDDEN_UPDATE_STYLES = frozenset({"rebase", "rebase-merge", "squash", "force"})
|
||||||
|
|
||||||
|
# ── Commit check classifications (#751) ──────────────────────────────────
|
||||||
|
# Gitea's *combined* commit status reports ``state: pending`` both when a real
|
||||||
|
# check is executing and when the status-context collection is empty. Reading
|
||||||
|
# ``state`` alone therefore cannot distinguish "CI is running" from "no CI
|
||||||
|
# exists", which permanently blocks a merge-ready PR that no check will ever
|
||||||
|
# report on. These classifications are derived from the actual context
|
||||||
|
# collection plus the live branch-protection policy.
|
||||||
|
CHECKS_SUCCESS = "success"
|
||||||
|
CHECKS_FAILURE = "failure"
|
||||||
|
CHECKS_PENDING = "pending"
|
||||||
|
CHECKS_NONE = "none" # configured/produced nothing
|
||||||
|
CHECKS_NOT_REQUIRED = "not_required" # protection does not require checks
|
||||||
|
CHECKS_MISSING_REQUIRED = "missing_required" # required contexts have no result
|
||||||
|
CHECKS_UNKNOWN = "unknown" # indeterminable — fail closed
|
||||||
|
|
||||||
|
# Values that permit merge_now when checks are required.
|
||||||
|
_CHECKS_OK = frozenset({"success", "passed", "ok", "skipped", "not_required"})
|
||||||
|
|
||||||
|
# Raw per-context state vocabularies reported by Gitea.
|
||||||
|
_CTX_SUCCESS = frozenset({"success", "passed", "ok"})
|
||||||
|
_CTX_FAILURE = frozenset({"failure", "failed", "error", "cancelled", "canceled"})
|
||||||
|
_CTX_PENDING = frozenset({"pending", "running", "queued", "expected"})
|
||||||
|
_CTX_SKIPPED = frozenset({"skipped", "neutral"})
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_context_rows(statuses: Any) -> list[dict[str, str]]:
|
||||||
|
"""Reduce a raw status collection to newest-wins ``{context, state}`` rows.
|
||||||
|
|
||||||
|
Gitea returns the status collection newest-first, so the first row seen for
|
||||||
|
a context wins. Rows without a usable state are discarded rather than being
|
||||||
|
silently treated as passing.
|
||||||
|
"""
|
||||||
|
rows: list[dict[str, str]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
if not isinstance(statuses, list):
|
||||||
|
return rows
|
||||||
|
for raw in statuses:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
context = (raw.get("context") or raw.get("name") or "").strip()
|
||||||
|
state = (raw.get("status") or raw.get("state") or "").strip().lower()
|
||||||
|
if not state:
|
||||||
|
continue
|
||||||
|
key = context or f"__unnamed__{len(rows)}"
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
rows.append({"context": context, "state": state})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate_context_states(rows: list[dict[str, str]]) -> str:
|
||||||
|
"""Fail-closed aggregate: failure > pending > unknown-state > success."""
|
||||||
|
states = {row["state"] for row in rows}
|
||||||
|
if states & _CTX_FAILURE:
|
||||||
|
return CHECKS_FAILURE
|
||||||
|
if states & _CTX_PENDING:
|
||||||
|
return CHECKS_PENDING
|
||||||
|
unresolved = states - _CTX_SUCCESS - _CTX_SKIPPED
|
||||||
|
if unresolved:
|
||||||
|
# An unrecognized context state must never read as success.
|
||||||
|
return CHECKS_UNKNOWN
|
||||||
|
return CHECKS_SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
def classify_commit_checks(
|
||||||
|
*,
|
||||||
|
combined_state: str | None = None,
|
||||||
|
statuses: Any = None,
|
||||||
|
checks_enabled: bool | None = None,
|
||||||
|
required_contexts: Any = None,
|
||||||
|
policy_determinable: bool = True,
|
||||||
|
status_determinable: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Classify head checks from live evidence (#751).
|
||||||
|
|
||||||
|
``combined_state`` is deliberately **not** authoritative: it is recorded for
|
||||||
|
observability but never used to infer that CI is executing. The context
|
||||||
|
collection and the live protection policy decide.
|
||||||
|
|
||||||
|
Returns ``checks_status`` (one of the ``CHECKS_*`` values), the derived
|
||||||
|
``checks_required`` flag, and structured ``reasons``.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
rows = _normalize_context_rows(statuses)
|
||||||
|
required = [
|
||||||
|
str(ctx).strip()
|
||||||
|
for ctx in (required_contexts or [])
|
||||||
|
if str(ctx or "").strip()
|
||||||
|
]
|
||||||
|
observed_combined = (combined_state or "").strip().lower() or None
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"checks_status": CHECKS_UNKNOWN,
|
||||||
|
"checks_required": True,
|
||||||
|
"combined_state": observed_combined,
|
||||||
|
"context_count": len(rows),
|
||||||
|
"observed_contexts": [row["context"] for row in rows],
|
||||||
|
"required_contexts": required,
|
||||||
|
"missing_required_contexts": [],
|
||||||
|
"policy_determinable": bool(policy_determinable),
|
||||||
|
"status_determinable": bool(status_determinable),
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Policy unreadable → never assume checks are optional.
|
||||||
|
if not policy_determinable:
|
||||||
|
reasons.append(
|
||||||
|
"branch-protection check policy could not be read; cannot prove "
|
||||||
|
"whether status checks are required (fail closed)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if checks_enabled is False:
|
||||||
|
result["checks_required"] = False
|
||||||
|
result["checks_status"] = CHECKS_NOT_REQUIRED
|
||||||
|
reasons.append(
|
||||||
|
"live branch protection does not require status checks for the base "
|
||||||
|
"branch; head status contexts do not gate merge"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if checks_enabled is None:
|
||||||
|
reasons.append(
|
||||||
|
"branch-protection status-check requirement is indeterminate "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Checks are required from here on.
|
||||||
|
if not status_determinable:
|
||||||
|
reasons.append(
|
||||||
|
"head commit status collection could not be read while branch "
|
||||||
|
"protection requires status checks (fail closed)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if required:
|
||||||
|
by_context = {row["context"]: row["state"] for row in rows if row["context"]}
|
||||||
|
missing = [ctx for ctx in required if ctx not in by_context]
|
||||||
|
if missing:
|
||||||
|
result["missing_required_contexts"] = missing
|
||||||
|
result["checks_status"] = CHECKS_MISSING_REQUIRED
|
||||||
|
reasons.append(
|
||||||
|
"branch protection requires status context(s) "
|
||||||
|
f"{', '.join(missing)} but no matching status result exists at "
|
||||||
|
"the head commit (fail closed)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
matched = [
|
||||||
|
{"context": ctx, "state": by_context[ctx]} for ctx in required
|
||||||
|
]
|
||||||
|
result["checks_status"] = _aggregate_context_states(matched)
|
||||||
|
reasons.append(
|
||||||
|
f"evaluated {len(matched)} required status context(s) from live "
|
||||||
|
"branch protection; unrelated contexts were ignored"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Status checks enabled with no specific required contexts configured.
|
||||||
|
if not rows:
|
||||||
|
result["checks_status"] = CHECKS_NONE
|
||||||
|
reasons.append(
|
||||||
|
"branch protection enables status checks but no status context was "
|
||||||
|
"produced for the head commit"
|
||||||
|
)
|
||||||
|
if observed_combined in _CTX_PENDING:
|
||||||
|
reasons.append(
|
||||||
|
f"combined commit state '{observed_combined}' does not indicate "
|
||||||
|
"executing CI because the status-context collection is empty"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["checks_status"] = _aggregate_context_states(rows)
|
||||||
|
reasons.append(
|
||||||
|
f"aggregated {len(rows)} reported status context(s); branch protection "
|
||||||
|
"configures no explicit required-context list"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _normalize_sha(value: str | None) -> str | None:
|
def _normalize_sha(value: str | None) -> str | None:
|
||||||
text = (value or "").strip().lower()
|
text = (value or "").strip().lower()
|
||||||
@@ -120,6 +300,7 @@ def assess_pr_sync_status(
|
|||||||
"branch_protection_requires_current_base": requires_current,
|
"branch_protection_requires_current_base": requires_current,
|
||||||
"approval_at_current_head": approval_ok if approval_at_current_head is not None else None,
|
"approval_at_current_head": approval_ok if approval_at_current_head is not None else None,
|
||||||
"checks_status": checks,
|
"checks_status": checks,
|
||||||
|
"checks_required": bool(checks_required),
|
||||||
"active_locks_and_leases": {
|
"active_locks_and_leases": {
|
||||||
"author_lock": bool(active_author_lock) if active_author_lock is not None else None,
|
"author_lock": bool(active_author_lock) if active_author_lock is not None else None,
|
||||||
"reviewer_lease": bool(active_reviewer_lease) if active_reviewer_lease is not None else None,
|
"reviewer_lease": bool(active_reviewer_lease) if active_reviewer_lease is not None else None,
|
||||||
@@ -258,21 +439,41 @@ def assess_pr_sync_status(
|
|||||||
result["recommended_next_action"] = ACTION_BLOCKED
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ── Checks gate for merge_now ────────────────────────────────────────
|
# ── Checks gate for merge_now (#751) ─────────────────────────────────
|
||||||
if checks_required and checks not in ("success", "passed", "ok", "none", "skipped", "not_required"):
|
# ``checks_required`` is derived from the live branch-protection policy by
|
||||||
if checks in ("pending", "running", "queued"):
|
# the production caller. When protection does not require status checks,
|
||||||
|
# head contexts cannot gate the merge and this whole gate is skipped.
|
||||||
|
if not checks_required:
|
||||||
|
reasons.append(
|
||||||
|
"live branch protection does not require status checks; head check "
|
||||||
|
f"state ({checks}) does not gate merge"
|
||||||
|
)
|
||||||
|
elif checks not in _CHECKS_OK:
|
||||||
|
if checks in _CTX_PENDING:
|
||||||
reasons.append(f"required checks are not finished (status={checks})")
|
reasons.append(f"required checks are not finished (status={checks})")
|
||||||
result["recommended_next_action"] = ACTION_BLOCKED
|
elif checks in _CTX_FAILURE:
|
||||||
return result
|
|
||||||
if checks in ("failure", "failed", "error", "cancelled"):
|
|
||||||
reasons.append(f"required checks failed (status={checks})")
|
reasons.append(f"required checks failed (status={checks})")
|
||||||
result["recommended_next_action"] = ACTION_BLOCKED
|
elif checks == CHECKS_MISSING_REQUIRED:
|
||||||
return result
|
reasons.append(
|
||||||
# unknown — fail closed when checks_required
|
"branch protection configures required status context(s) but no "
|
||||||
if checks == "unknown":
|
"matching status result exists at the current head (fail closed)"
|
||||||
|
)
|
||||||
|
elif checks == CHECKS_NONE:
|
||||||
|
reasons.append(
|
||||||
|
"branch protection requires status checks but no status context "
|
||||||
|
"was produced for the current head (fail closed); an empty "
|
||||||
|
"status collection is not executing CI"
|
||||||
|
)
|
||||||
|
elif checks == CHECKS_UNKNOWN:
|
||||||
reasons.append("checks status unknown (fail closed)")
|
reasons.append("checks status unknown (fail closed)")
|
||||||
result["recommended_next_action"] = ACTION_BLOCKED
|
else:
|
||||||
return result
|
# Unrecognized vocabulary must never fall through to merge_now.
|
||||||
|
reasons.append(
|
||||||
|
f"unrecognized checks status '{checks}' cannot prove required "
|
||||||
|
"checks passed (fail closed)"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
|
||||||
# ── Ready to merge without update ────────────────────────────────────
|
# ── Ready to merge without update ────────────────────────────────────
|
||||||
# Includes: current with approval; outdated when update is NOT required.
|
# Includes: current with approval; outdated when update is NOT required.
|
||||||
|
|||||||
+2
-1
@@ -36,7 +36,8 @@ _TERMINAL_CONFLICT_FIX_PHASES = frozenset({"released", "blocked", "done"})
|
|||||||
_ACTIVE_CONFLICT_FIX_PHASES = frozenset({"claimed", "pushing", "pushed"})
|
_ACTIVE_CONFLICT_FIX_PHASES = frozenset({"claimed", "pushing", "pushed"})
|
||||||
|
|
||||||
DEFAULT_CONFLICT_FIX_TTL_MINUTES = 120
|
DEFAULT_CONFLICT_FIX_TTL_MINUTES = 120
|
||||||
DEFAULT_REVIEWER_LEASE_TTL_MINUTES = 120
|
# The reviewer/merger PR-lease TTL lives in reviewer_pr_lease.LEASE_TTL_MINUTES
|
||||||
|
# (#747). A second copy here had no readers and could only drift out of sync.
|
||||||
|
|
||||||
|
|
||||||
def _parse_timestamp(value: str | None) -> datetime | None:
|
def _parse_timestamp(value: str | None) -> datetime | None:
|
||||||
|
|||||||
+46
-7
@@ -29,9 +29,19 @@ _ACTIVE_PHASES = frozenset({
|
|||||||
"adopted",
|
"adopted",
|
||||||
})
|
})
|
||||||
|
|
||||||
DEFAULT_LEASE_TTL_MINUTES = 120
|
# Reviewer and merger PR leases use a short *sliding* window (#747): a lease
|
||||||
STALE_WARNING_MINUTES = 30
|
# expires 10 minutes after its last heartbeat, and every heartbeat slides the
|
||||||
RECLAIMABLE_MINUTES = 60
|
# expiry forward. An actively heartbeating session is never evicted, while a
|
||||||
|
# dead session releases its hold in at most one TTL instead of the two hours
|
||||||
|
# the previous fixed 120-minute expiry allowed.
|
||||||
|
LEASE_TTL_MINUTES = 10
|
||||||
|
# Renewal is named separately from acquisition so the slide amount is tunable
|
||||||
|
# without silently re-defining how long a fresh lease lives.
|
||||||
|
LEASE_RENEWAL_MINUTES = 10
|
||||||
|
# Retained for callers that imported the pre-#747 name.
|
||||||
|
DEFAULT_LEASE_TTL_MINUTES = LEASE_TTL_MINUTES
|
||||||
|
# Warn at half the window, while the owner can still heartbeat and recover.
|
||||||
|
STALE_WARNING_MINUTES = 5
|
||||||
|
|
||||||
_SESSION_LEASE: dict[str, Any] | None = None
|
_SESSION_LEASE: dict[str, Any] | None = None
|
||||||
|
|
||||||
@@ -80,10 +90,19 @@ def format_lease_body(
|
|||||||
target_branch_sha: str | None,
|
target_branch_sha: str | None,
|
||||||
last_activity: datetime | None = None,
|
last_activity: datetime | None = None,
|
||||||
expires_at: datetime | None = None,
|
expires_at: datetime | None = None,
|
||||||
|
ttl_minutes: int = LEASE_TTL_MINUTES,
|
||||||
blocker: str = "none",
|
blocker: str = "none",
|
||||||
) -> str:
|
) -> str:
|
||||||
|
"""Serialize a lease marker.
|
||||||
|
|
||||||
|
Every write of this marker — acquisition, heartbeat, adoption — re-derives
|
||||||
|
``expires_at`` from the moment of the write, which is what makes the TTL
|
||||||
|
slide (#747). Callers renewing an existing lease pass
|
||||||
|
``ttl_minutes=LEASE_RENEWAL_MINUTES``; an explicit ``expires_at`` still
|
||||||
|
wins so a lease can be minted with a deliberate window.
|
||||||
|
"""
|
||||||
now = last_activity or datetime.now(timezone.utc)
|
now = last_activity or datetime.now(timezone.utc)
|
||||||
expires = expires_at or (now + timedelta(minutes=DEFAULT_LEASE_TTL_MINUTES))
|
expires = expires_at or (now + timedelta(minutes=ttl_minutes))
|
||||||
last_text = now.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
last_text = now.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
)
|
)
|
||||||
@@ -169,8 +188,30 @@ def _minutes_since_activity(lease: dict, *, now: datetime) -> float | None:
|
|||||||
return (now - last).total_seconds() / 60.0
|
return (now - last).total_seconds() / 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def lease_seconds_remaining(lease: dict, *, now: datetime | None = None) -> int | None:
|
||||||
|
"""Seconds until *lease* expires, clamped at 0; ``None`` if unparsable.
|
||||||
|
|
||||||
|
Lets diagnostics distinguish "held and live" from "held and dying" (#747)
|
||||||
|
rather than only reporting that a lease exists.
|
||||||
|
"""
|
||||||
|
expires_at = _parse_timestamp(lease.get("expires_at"))
|
||||||
|
if not expires_at:
|
||||||
|
return None
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
return max(0, int((expires_at - now).total_seconds()))
|
||||||
|
|
||||||
|
|
||||||
def classify_lease_freshness(lease: dict, *, now: datetime | None = None) -> str:
|
def classify_lease_freshness(lease: dict, *, now: datetime | None = None) -> str:
|
||||||
"""Return active, stale_warning, reclaimable, expired, or terminal."""
|
"""Return active, stale_warning, expired, or terminal.
|
||||||
|
|
||||||
|
Expiry is the only takeover gate (#747). The pre-#747 ``reclaimable`` band
|
||||||
|
sat between "stale" and "expired" and made a dead lease wait out a second
|
||||||
|
timer before anyone could reclaim it. Under a sliding TTL that band is also
|
||||||
|
unreachable: a heartbeat stamps ``last_activity`` and ``expires_at``
|
||||||
|
together, so a lease idle for a full TTL is already expired. Foreign
|
||||||
|
expired leases are handled by the ``foreign_expired`` classification, which
|
||||||
|
carries the same sanctioned release next-action the old tier did.
|
||||||
|
"""
|
||||||
now = now or datetime.now(timezone.utc)
|
now = now or datetime.now(timezone.utc)
|
||||||
phase = (lease.get("phase") or "").strip().lower()
|
phase = (lease.get("phase") or "").strip().lower()
|
||||||
if phase in _TERMINAL_PHASES:
|
if phase in _TERMINAL_PHASES:
|
||||||
@@ -180,8 +221,6 @@ def classify_lease_freshness(lease: dict, *, now: datetime | None = None) -> str
|
|||||||
minutes = _minutes_since_activity(lease, now=now)
|
minutes = _minutes_since_activity(lease, now=now)
|
||||||
if minutes is None:
|
if minutes is None:
|
||||||
return "active"
|
return "active"
|
||||||
if minutes >= RECLAIMABLE_MINUTES:
|
|
||||||
return "reclaimable"
|
|
||||||
if minutes >= STALE_WARNING_MINUTES:
|
if minutes >= STALE_WARNING_MINUTES:
|
||||||
return "stale_warning"
|
return "stale_warning"
|
||||||
return "active"
|
return "active"
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Tests for the 10-minute sliding TTL on reviewer and merger PR leases (#747).
|
||||||
|
|
||||||
|
The lease ledger previously minted a fixed 120-minute expiry and derived
|
||||||
|
staleness from separate 30/60-minute activity bands. A dead session therefore
|
||||||
|
held a PR for up to two hours. These tests pin the sliding-window contract:
|
||||||
|
acquisition mints a 10-minute expiry, every heartbeat slides it forward, and an
|
||||||
|
expired lease is immediately reclaimable with no intermediate waiting tier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import reviewer_pr_lease as leases
|
||||||
|
|
||||||
|
|
||||||
|
def _body(
|
||||||
|
*,
|
||||||
|
session_id: str = "session-a",
|
||||||
|
pr_number: int = 747,
|
||||||
|
phase: str = "claimed",
|
||||||
|
last_activity: datetime | None = None,
|
||||||
|
expires_at: datetime | None = None,
|
||||||
|
ttl_minutes: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
kwargs = {}
|
||||||
|
if ttl_minutes is not None:
|
||||||
|
kwargs["ttl_minutes"] = ttl_minutes
|
||||||
|
return leases.format_lease_body(
|
||||||
|
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
pr_number=pr_number,
|
||||||
|
issue_number=747,
|
||||||
|
reviewer_identity="rev1",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id=session_id,
|
||||||
|
worktree="branches/review-pr747",
|
||||||
|
phase=phase,
|
||||||
|
candidate_head="a" * 40,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="b" * 40,
|
||||||
|
last_activity=last_activity,
|
||||||
|
expires_at=expires_at,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _comment(**kwargs) -> dict:
|
||||||
|
return {"id": 1, "body": _body(**kwargs), "user": {"login": "rev1"}}
|
||||||
|
|
||||||
|
|
||||||
|
def _minutes_ago(minutes: int) -> datetime:
|
||||||
|
return datetime.now(timezone.utc) - timedelta(minutes=minutes)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSlidingTTLConstant(unittest.TestCase):
|
||||||
|
"""AC6: one named constant per lease kind, no duplicated literals."""
|
||||||
|
|
||||||
|
def test_ttl_is_ten_minutes(self):
|
||||||
|
self.assertEqual(leases.LEASE_TTL_MINUTES, 10)
|
||||||
|
|
||||||
|
def test_renewal_window_is_separately_named(self):
|
||||||
|
self.assertEqual(leases.LEASE_RENEWAL_MINUTES, 10)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAcquisitionTTL(unittest.TestCase):
|
||||||
|
"""AC1 / AC2: reviewer and merger acquisition both mint now + 10 minutes."""
|
||||||
|
|
||||||
|
def test_acquire_mints_ten_minute_expiry(self):
|
||||||
|
now = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
lease = leases.parse_lease_comment(_body(last_activity=now))
|
||||||
|
expires = leases._parse_timestamp(lease["expires_at"])
|
||||||
|
self.assertEqual(expires, now + timedelta(minutes=10))
|
||||||
|
|
||||||
|
def test_merger_acquisition_shares_the_same_window(self):
|
||||||
|
# Merger acquisition funnels through the same lease-body formatter, so
|
||||||
|
# the reviewer TTL is the merger TTL by construction.
|
||||||
|
now = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
lease = leases.parse_lease_comment(_body(phase="merging", last_activity=now))
|
||||||
|
expires = leases._parse_timestamp(lease["expires_at"])
|
||||||
|
self.assertEqual(expires, now + timedelta(minutes=10))
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeartbeatSlides(unittest.TestCase):
|
||||||
|
"""AC3: a heartbeat slides expires_at to now + 10 minutes."""
|
||||||
|
|
||||||
|
def test_heartbeat_slides_expiry_forward(self):
|
||||||
|
acquired = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
beat = acquired + timedelta(minutes=7)
|
||||||
|
first = leases.parse_lease_comment(_body(last_activity=acquired))
|
||||||
|
renewed = leases.parse_lease_comment(_body(last_activity=beat))
|
||||||
|
|
||||||
|
first_expiry = leases._parse_timestamp(first["expires_at"])
|
||||||
|
renewed_expiry = leases._parse_timestamp(renewed["expires_at"])
|
||||||
|
|
||||||
|
self.assertEqual(renewed_expiry, beat + timedelta(minutes=10))
|
||||||
|
self.assertGreater(renewed_expiry, first_expiry)
|
||||||
|
|
||||||
|
def test_renewal_window_is_independently_tunable(self):
|
||||||
|
# The renewal amount must not be hardwired to the acquisition TTL;
|
||||||
|
# format_lease_body accepts an explicit window.
|
||||||
|
now = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
lease = leases.parse_lease_comment(_body(last_activity=now, ttl_minutes=3))
|
||||||
|
expires = leases._parse_timestamp(lease["expires_at"])
|
||||||
|
self.assertEqual(expires, now + timedelta(minutes=3))
|
||||||
|
|
||||||
|
|
||||||
|
class TestFreshnessBands(unittest.TestCase):
|
||||||
|
"""AC5: expiry is the only gate; no intermediate reclaim tier."""
|
||||||
|
|
||||||
|
def test_fresh_lease_is_active(self):
|
||||||
|
lease = leases.parse_lease_comment(_body(last_activity=_minutes_ago(1)))
|
||||||
|
self.assertEqual(leases.classify_lease_freshness(lease), "active")
|
||||||
|
|
||||||
|
def test_idle_past_half_ttl_warns_before_expiry(self):
|
||||||
|
lease = leases.parse_lease_comment(_body(last_activity=_minutes_ago(6)))
|
||||||
|
self.assertEqual(leases.classify_lease_freshness(lease), "stale_warning")
|
||||||
|
|
||||||
|
def test_lease_expires_after_ten_idle_minutes(self):
|
||||||
|
lease = leases.parse_lease_comment(_body(last_activity=_minutes_ago(11)))
|
||||||
|
self.assertEqual(leases.classify_lease_freshness(lease), "expired")
|
||||||
|
|
||||||
|
def test_no_separate_reclaimable_tier_remains(self):
|
||||||
|
# The old 60-minute reclaim band sat between "stale" and "expired" and
|
||||||
|
# blocked acquisition. Under a sliding TTL an idle lease is already
|
||||||
|
# expired, so the tier must not reappear at any idle duration.
|
||||||
|
for minutes in (11, 30, 65, 121, 600):
|
||||||
|
lease = leases.parse_lease_comment(_body(last_activity=_minutes_ago(minutes)))
|
||||||
|
self.assertEqual(
|
||||||
|
leases.classify_lease_freshness(lease),
|
||||||
|
"expired",
|
||||||
|
f"idle {minutes}m should be expired, not a waiting tier",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpiredLeaseIsImmediatelyReclaimable(unittest.TestCase):
|
||||||
|
"""AC5: another session takes over an expired lease with no extra wait."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
leases.clear_session_lease()
|
||||||
|
|
||||||
|
def _acquire_against(self, comments: list[dict]) -> dict:
|
||||||
|
return leases.assess_acquire_lease(
|
||||||
|
comments,
|
||||||
|
pr_number=747,
|
||||||
|
reviewer_identity="rev2",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id="session-b",
|
||||||
|
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
issue_number=747,
|
||||||
|
worktree="branches/review-pr747-b",
|
||||||
|
candidate_head="c" * 40,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="d" * 40,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_expired_foreign_lease_does_not_block_acquisition(self):
|
||||||
|
comments = [_comment(session_id="dead-session", last_activity=_minutes_ago(11))]
|
||||||
|
result = self._acquire_against(comments)
|
||||||
|
self.assertTrue(result["acquire_allowed"], result["reasons"])
|
||||||
|
|
||||||
|
def test_live_foreign_lease_still_blocks_acquisition(self):
|
||||||
|
comments = [_comment(session_id="live-session", last_activity=_minutes_ago(2))]
|
||||||
|
result = self._acquire_against(comments)
|
||||||
|
self.assertFalse(result["acquire_allowed"])
|
||||||
|
self.assertTrue(any("already has active" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemainingTimeReporting(unittest.TestCase):
|
||||||
|
"""AC7: diagnostics can distinguish 'held and live' from 'held and dying'."""
|
||||||
|
|
||||||
|
def test_seconds_remaining_on_live_lease(self):
|
||||||
|
now = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
lease = leases.parse_lease_comment(_body(last_activity=now))
|
||||||
|
remaining = leases.lease_seconds_remaining(lease, now=now + timedelta(minutes=4))
|
||||||
|
self.assertEqual(remaining, 360)
|
||||||
|
|
||||||
|
def test_seconds_remaining_is_zero_when_expired(self):
|
||||||
|
now = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
lease = leases.parse_lease_comment(_body(last_activity=now))
|
||||||
|
remaining = leases.lease_seconds_remaining(lease, now=now + timedelta(minutes=30))
|
||||||
|
self.assertEqual(remaining, 0)
|
||||||
|
|
||||||
|
def test_seconds_remaining_is_none_without_parsable_expiry(self):
|
||||||
|
self.assertIsNone(leases.lease_seconds_remaining({"expires_at": "not-a-time"}))
|
||||||
|
|
||||||
|
|
||||||
|
class TestLegacyLeaseRows(unittest.TestCase):
|
||||||
|
"""AC9: leases minted under the old 120-minute TTL still evaluate."""
|
||||||
|
|
||||||
|
def test_legacy_two_hour_expiry_is_honoured_until_it_passes(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
legacy = leases.parse_lease_comment(
|
||||||
|
_body(last_activity=now - timedelta(minutes=90), expires_at=now + timedelta(minutes=30))
|
||||||
|
)
|
||||||
|
# Still inside its originally minted window: not expired, but idle long
|
||||||
|
# enough to warn.
|
||||||
|
self.assertEqual(leases.classify_lease_freshness(legacy), "stale_warning")
|
||||||
|
|
||||||
|
def test_legacy_row_past_its_own_expiry_is_expired(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
legacy = leases.parse_lease_comment(
|
||||||
|
_body(last_activity=now - timedelta(minutes=180), expires_at=now - timedelta(minutes=60))
|
||||||
|
)
|
||||||
|
self.assertEqual(leases.classify_lease_freshness(legacy), "expired")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
"""Regression coverage for the PR checks assessor defect (#751).
|
||||||
|
|
||||||
|
Gitea's *combined* commit status reports ``state: pending`` both when a check is
|
||||||
|
executing and when the status-context collection is empty. The assessor read
|
||||||
|
``state`` alone and defaulted ``checks_required`` to ``True``, so a PR whose head
|
||||||
|
had no status contexts — and never would — was routed to ``blocked`` forever.
|
||||||
|
|
||||||
|
These tests pin the corrected semantics end to end: live branch protection
|
||||||
|
decides whether checks are required, and the actual context collection decides
|
||||||
|
what the checks say.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import pr_sync_status # noqa: E402
|
||||||
|
from pr_sync_status import ( # noqa: E402
|
||||||
|
ACTION_BLOCKED,
|
||||||
|
ACTION_MERGE_NOW,
|
||||||
|
ACTION_UPDATE_BRANCH_BY_MERGE,
|
||||||
|
CHECKS_FAILURE,
|
||||||
|
CHECKS_MISSING_REQUIRED,
|
||||||
|
CHECKS_NONE,
|
||||||
|
CHECKS_NOT_REQUIRED,
|
||||||
|
CHECKS_PENDING,
|
||||||
|
CHECKS_SUCCESS,
|
||||||
|
CHECKS_UNKNOWN,
|
||||||
|
assess_pr_sync_status,
|
||||||
|
classify_commit_checks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha(prefix: str) -> str:
|
||||||
|
return (prefix + "0" * 40)[:40]
|
||||||
|
|
||||||
|
|
||||||
|
PR_HEAD = _sha("aaaaaaaa")
|
||||||
|
BASE_HEAD = _sha("bbbbbbbb")
|
||||||
|
|
||||||
|
|
||||||
|
def _base_kwargs(**overrides):
|
||||||
|
data = {
|
||||||
|
"host": "gitea.prgs.cc",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Gitea-Tools",
|
||||||
|
"pr_number": 751,
|
||||||
|
"pr_state": "open",
|
||||||
|
"source_branch": "fix/issue-751-checks-assessor",
|
||||||
|
"pr_head_sha": PR_HEAD,
|
||||||
|
"base_head_sha": BASE_HEAD,
|
||||||
|
"commits_behind": 0,
|
||||||
|
"mergeable": True,
|
||||||
|
"has_conflicts": False,
|
||||||
|
"branch_protection_requires_current_base": False,
|
||||||
|
"approval_at_current_head": True,
|
||||||
|
"checks_status": "success",
|
||||||
|
"active_author_lock": True,
|
||||||
|
"active_reviewer_lease": False,
|
||||||
|
"active_merger_lease": False,
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _reasons(result) -> str:
|
||||||
|
return " | ".join(result["reasons"]).lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassifyCommitChecks(unittest.TestCase):
|
||||||
|
"""Pure classification from live evidence."""
|
||||||
|
|
||||||
|
def test_empty_collection_with_combined_pending_is_not_executing_ci(self):
|
||||||
|
# The exact PR #750 failure mode at the classification layer.
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="pending",
|
||||||
|
statuses=[],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=[],
|
||||||
|
)
|
||||||
|
self.assertNotEqual(result["checks_status"], CHECKS_PENDING)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_NONE)
|
||||||
|
self.assertEqual(result["context_count"], 0)
|
||||||
|
joined = " ".join(result["reasons"]).lower()
|
||||||
|
self.assertIn("empty", joined)
|
||||||
|
self.assertIn("does not indicate", joined)
|
||||||
|
|
||||||
|
def test_protection_disables_status_checks(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="pending",
|
||||||
|
statuses=[],
|
||||||
|
checks_enabled=False,
|
||||||
|
required_contexts=[],
|
||||||
|
)
|
||||||
|
self.assertFalse(result["checks_required"])
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_NOT_REQUIRED)
|
||||||
|
|
||||||
|
def test_no_required_contexts_aggregates_reported_contexts(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[{"context": "build", "status": "success"}],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=[],
|
||||||
|
)
|
||||||
|
self.assertTrue(result["checks_required"])
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_SUCCESS)
|
||||||
|
|
||||||
|
def test_required_checks_pending(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="pending",
|
||||||
|
statuses=[{"context": "build", "status": "pending"}],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_PENDING)
|
||||||
|
|
||||||
|
def test_required_checks_failed(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="failure",
|
||||||
|
statuses=[{"context": "build", "status": "failure"}],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_FAILURE)
|
||||||
|
|
||||||
|
def test_required_checks_successful(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[{"context": "build", "status": "success"}],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_SUCCESS)
|
||||||
|
self.assertTrue(result["checks_required"])
|
||||||
|
|
||||||
|
def test_required_context_configured_with_no_matching_result(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[{"context": "lint", "status": "success"}],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_MISSING_REQUIRED)
|
||||||
|
self.assertEqual(result["missing_required_contexts"], ["build"])
|
||||||
|
|
||||||
|
def test_mixed_required_and_unrelated_contexts_ignores_unrelated(self):
|
||||||
|
# An unrelated failing context must not fail a satisfied required set.
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="failure",
|
||||||
|
statuses=[
|
||||||
|
{"context": "build", "status": "success"},
|
||||||
|
{"context": "optional-scan", "status": "failure"},
|
||||||
|
],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_SUCCESS)
|
||||||
|
# ...and a failing *required* context still fails despite passing extras.
|
||||||
|
failing = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[
|
||||||
|
{"context": "build", "status": "failure"},
|
||||||
|
{"context": "optional-scan", "status": "success"},
|
||||||
|
],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(failing["checks_status"], CHECKS_FAILURE)
|
||||||
|
|
||||||
|
def test_policy_unreadable_fails_closed(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state=None,
|
||||||
|
statuses=[],
|
||||||
|
checks_enabled=None,
|
||||||
|
required_contexts=[],
|
||||||
|
policy_determinable=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["checks_required"])
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)
|
||||||
|
self.assertIn("fail closed", " ".join(result["reasons"]).lower())
|
||||||
|
|
||||||
|
def test_malformed_policy_indeterminate_flag_fails_closed(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[{"context": "build", "status": "success"}],
|
||||||
|
checks_enabled=None,
|
||||||
|
required_contexts=[],
|
||||||
|
policy_determinable=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["checks_required"])
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)
|
||||||
|
|
||||||
|
def test_status_collection_unreadable_fails_closed_when_required(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
status_determinable=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["checks_required"])
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)
|
||||||
|
|
||||||
|
def test_unrecognized_context_state_never_reads_as_success(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[{"context": "build", "status": "banana"}],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)
|
||||||
|
|
||||||
|
def test_newest_wins_per_context(self):
|
||||||
|
# Gitea returns newest-first; the stale failure must not win.
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[
|
||||||
|
{"context": "build", "status": "success"},
|
||||||
|
{"context": "build", "status": "failure"},
|
||||||
|
],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_SUCCESS)
|
||||||
|
|
||||||
|
def test_malformed_status_rows_are_discarded_not_treated_as_passing(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="pending",
|
||||||
|
statuses=[{"context": "build"}, "not-a-dict", None],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_MISSING_REQUIRED)
|
||||||
|
|
||||||
|
|
||||||
|
class TestChecksGateSemantics(unittest.TestCase):
|
||||||
|
"""``assess_pr_sync_status`` routing and blocker reasons."""
|
||||||
|
|
||||||
|
def test_not_required_allows_merge_now_with_empty_checks(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_NOT_REQUIRED),
|
||||||
|
checks_required=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||||
|
self.assertTrue(result["approval_valid_for_merge"])
|
||||||
|
self.assertFalse(result["checks_required"])
|
||||||
|
self.assertIn("does not require status checks", _reasons(result))
|
||||||
|
|
||||||
|
def test_none_blocks_when_checks_required(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_NONE),
|
||||||
|
checks_required=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertIn("no status context", _reasons(result))
|
||||||
|
self.assertIn("not executing ci", _reasons(result))
|
||||||
|
|
||||||
|
def test_missing_required_blocks(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_MISSING_REQUIRED),
|
||||||
|
checks_required=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertIn("no matching status result", _reasons(result))
|
||||||
|
|
||||||
|
def test_required_pending_blocks(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_PENDING), checks_required=True
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertIn("not finished", _reasons(result))
|
||||||
|
|
||||||
|
def test_required_failure_blocks(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_FAILURE), checks_required=True
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertIn("failed", _reasons(result))
|
||||||
|
|
||||||
|
def test_required_success_merges(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_SUCCESS), checks_required=True
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||||
|
|
||||||
|
def test_unknown_blocks_when_required(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_UNKNOWN), checks_required=True
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertIn("unknown", _reasons(result))
|
||||||
|
|
||||||
|
def test_unrecognized_status_does_not_fall_through_to_merge(self):
|
||||||
|
# Regression: the previous gate only handled a fixed vocabulary and let
|
||||||
|
# anything else reach merge_now.
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status="totally-unexpected"),
|
||||||
|
checks_required=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertIn("unrecognized checks status", _reasons(result))
|
||||||
|
|
||||||
|
def test_checks_gate_does_not_bypass_approval_requirement(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(
|
||||||
|
checks_status=CHECKS_NOT_REQUIRED, approval_at_current_head=False
|
||||||
|
),
|
||||||
|
checks_required=False,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||||
|
self.assertFalse(result["approval_valid_for_merge"])
|
||||||
|
|
||||||
|
def test_checks_gate_does_not_bypass_current_base_protection(self):
|
||||||
|
# Existing current-base behavior stays intact (#727).
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(
|
||||||
|
checks_status=CHECKS_NOT_REQUIRED,
|
||||||
|
commits_behind=3,
|
||||||
|
branch_protection_requires_current_base=True,
|
||||||
|
),
|
||||||
|
checks_required=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result["recommended_next_action"], ACTION_UPDATE_BRANCH_BY_MERGE
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_checks_gate_does_not_bypass_conflicts(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_NOT_REQUIRED, mergeable=False),
|
||||||
|
checks_required=False,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBranchProtectionPolicy(unittest.TestCase):
|
||||||
|
"""Live protection reader derives the status-check requirement."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
import gitea_mcp_server as gms
|
||||||
|
|
||||||
|
self.gms = gms
|
||||||
|
|
||||||
|
def _policy(self, responses):
|
||||||
|
def fake_api_request(method, url, auth, *args, **kwargs):
|
||||||
|
for fragment, payload in responses.items():
|
||||||
|
if fragment in url:
|
||||||
|
if isinstance(payload, Exception):
|
||||||
|
raise payload
|
||||||
|
return payload
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch.object(self.gms, "api_request", side_effect=fake_api_request):
|
||||||
|
return self.gms._branch_protection_policy(
|
||||||
|
"https://example/api/v1/repos/o/r", {"h": "1"}, base_branch="master"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_status_checks_enabled_with_contexts(self):
|
||||||
|
policy = self._policy({
|
||||||
|
"branch_protections": [{
|
||||||
|
"branch_name": "master",
|
||||||
|
"block_on_outdated_branch": True,
|
||||||
|
"enable_status_check": True,
|
||||||
|
"status_check_contexts": ["ci/build", " "],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
self.assertTrue(policy["determinable"])
|
||||||
|
self.assertTrue(policy["checks_enabled"])
|
||||||
|
self.assertTrue(policy["requires_current_base"])
|
||||||
|
self.assertEqual(policy["required_contexts"], ["ci/build"])
|
||||||
|
|
||||||
|
def test_status_checks_disabled(self):
|
||||||
|
policy = self._policy({
|
||||||
|
"branch_protections": [{
|
||||||
|
"branch_name": "master",
|
||||||
|
"enable_status_check": False,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
self.assertTrue(policy["determinable"])
|
||||||
|
self.assertFalse(policy["checks_enabled"])
|
||||||
|
|
||||||
|
def test_no_protection_rule_means_checks_not_required(self):
|
||||||
|
# PR #750's repository shape: protection list readable but empty.
|
||||||
|
policy = self._policy({"branch_protections": [], "branches/master": {}})
|
||||||
|
self.assertTrue(policy["determinable"])
|
||||||
|
self.assertFalse(policy["protection_found"])
|
||||||
|
self.assertFalse(policy["checks_enabled"])
|
||||||
|
self.assertIsNone(policy["requires_current_base"])
|
||||||
|
|
||||||
|
def test_protection_without_status_field_means_not_enabled(self):
|
||||||
|
policy = self._policy({
|
||||||
|
"branch_protections": [{
|
||||||
|
"branch_name": "master",
|
||||||
|
"block_on_outdated_branch": True,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
self.assertTrue(policy["determinable"])
|
||||||
|
self.assertFalse(policy["checks_enabled"])
|
||||||
|
self.assertTrue(policy["requires_current_base"])
|
||||||
|
|
||||||
|
def test_api_failure_is_not_determinable(self):
|
||||||
|
policy = self._policy({
|
||||||
|
"branch_protections": RuntimeError("boom"),
|
||||||
|
})
|
||||||
|
self.assertFalse(policy["determinable"])
|
||||||
|
self.assertIsNone(policy["checks_enabled"])
|
||||||
|
|
||||||
|
def test_missing_branch_is_not_determinable(self):
|
||||||
|
with patch.object(self.gms, "api_request", return_value=[]):
|
||||||
|
policy = self.gms._branch_protection_policy(
|
||||||
|
"https://example/api/v1/repos/o/r", {}, base_branch=" "
|
||||||
|
)
|
||||||
|
self.assertFalse(policy["determinable"])
|
||||||
|
|
||||||
|
def test_requires_current_base_accessor_preserved(self):
|
||||||
|
def fake(method, url, auth, *a, **k):
|
||||||
|
if "branch_protections" in url:
|
||||||
|
return [{"branch_name": "master",
|
||||||
|
"block_on_outdated_branch": True}]
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch.object(self.gms, "api_request", side_effect=fake):
|
||||||
|
value = self.gms._branch_protection_requires_current_base(
|
||||||
|
"https://example/api/v1/repos/o/r", {}, base_branch="master"
|
||||||
|
)
|
||||||
|
self.assertTrue(value)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommitChecksSnapshot(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
import gitea_mcp_server as gms
|
||||||
|
|
||||||
|
self.gms = gms
|
||||||
|
|
||||||
|
def test_reads_state_and_context_collection(self):
|
||||||
|
payload = {"state": "pending", "statuses": [{"context": "b",
|
||||||
|
"status": "pending"}]}
|
||||||
|
with patch.object(self.gms, "api_request", return_value=payload):
|
||||||
|
snap = self.gms._commit_checks_snapshot(
|
||||||
|
"https://example/api/v1/repos/o/r", {}, sha=PR_HEAD
|
||||||
|
)
|
||||||
|
self.assertTrue(snap["determinable"])
|
||||||
|
self.assertEqual(snap["combined_state"], "pending")
|
||||||
|
self.assertEqual(len(snap["statuses"]), 1)
|
||||||
|
|
||||||
|
def test_empty_collection_recorded_as_determinable(self):
|
||||||
|
with patch.object(
|
||||||
|
self.gms, "api_request", return_value={"state": "pending", "statuses": []}
|
||||||
|
):
|
||||||
|
snap = self.gms._commit_checks_snapshot(
|
||||||
|
"https://example/api/v1/repos/o/r", {}, sha=PR_HEAD
|
||||||
|
)
|
||||||
|
self.assertTrue(snap["determinable"])
|
||||||
|
self.assertEqual(snap["statuses"], [])
|
||||||
|
|
||||||
|
def test_api_failure_is_not_determinable(self):
|
||||||
|
with patch.object(self.gms, "api_request", side_effect=RuntimeError("x")):
|
||||||
|
snap = self.gms._commit_checks_snapshot(
|
||||||
|
"https://example/api/v1/repos/o/r", {}, sha=PR_HEAD
|
||||||
|
)
|
||||||
|
self.assertFalse(snap["determinable"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestMcpWrapperForwarding(unittest.TestCase):
|
||||||
|
"""The production MCP path must reach the derived ``checks_required``."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
import gitea_mcp_server as gms
|
||||||
|
|
||||||
|
self.gms = gms
|
||||||
|
self.base = (
|
||||||
|
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Gitea-Tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _patches(self, fake_api_request, spy):
|
||||||
|
return [
|
||||||
|
patch.object(self.gms, "_profile_operation_gate", return_value=None),
|
||||||
|
patch.object(self.gms, "_resolve", return_value=(
|
||||||
|
"gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools")),
|
||||||
|
patch.object(self.gms, "_auth", return_value={"Authorization": "x"}),
|
||||||
|
patch.object(self.gms, "repo_api_url", return_value=self.base),
|
||||||
|
patch.object(self.gms, "api_request", side_effect=fake_api_request),
|
||||||
|
patch.object(self.gms, "gitea_get_pr_review_feedback", return_value={
|
||||||
|
"success": True, "approval_at_current_head": True}),
|
||||||
|
patch.object(self.gms, "_prove_author_ownership_for_pr", return_value={
|
||||||
|
"has_author_lock": True}),
|
||||||
|
patch.object(pr_sync_status, "assess_pr_sync_status", side_effect=spy),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _run(self, *, protections, status_payload, pr_number=750,
|
||||||
|
caller_checks_status=None):
|
||||||
|
captured = {}
|
||||||
|
real_assess = pr_sync_status.assess_pr_sync_status
|
||||||
|
|
||||||
|
def spy(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return real_assess(**kwargs)
|
||||||
|
|
||||||
|
def fake_api_request(method, url, auth, *args, **kwargs):
|
||||||
|
if f"/pulls/{pr_number}" in url:
|
||||||
|
return {
|
||||||
|
"number": pr_number,
|
||||||
|
"state": "open",
|
||||||
|
"title": "t",
|
||||||
|
"body": "b",
|
||||||
|
"mergeable": True,
|
||||||
|
"head": {"sha": PR_HEAD, "ref": "fix/x"},
|
||||||
|
"base": {"sha": BASE_HEAD, "ref": "master"},
|
||||||
|
}
|
||||||
|
if "/branch_protections" in url:
|
||||||
|
if isinstance(protections, Exception):
|
||||||
|
raise protections
|
||||||
|
return protections
|
||||||
|
if "/branches/master" in url:
|
||||||
|
return {"commit": {"id": BASE_HEAD}}
|
||||||
|
if "/status" in url:
|
||||||
|
if isinstance(status_payload, Exception):
|
||||||
|
raise status_payload
|
||||||
|
return status_payload
|
||||||
|
if "/compare/" in url:
|
||||||
|
return {"total_commits": 0}
|
||||||
|
if "/comments" in url:
|
||||||
|
return []
|
||||||
|
return None
|
||||||
|
|
||||||
|
stack = self._patches(fake_api_request, spy)
|
||||||
|
for p in stack:
|
||||||
|
p.start()
|
||||||
|
try:
|
||||||
|
result = self.gms.gitea_assess_pr_sync_status(
|
||||||
|
pr_number=pr_number, remote="prgs",
|
||||||
|
checks_status=caller_checks_status,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
for p in reversed(stack):
|
||||||
|
p.stop()
|
||||||
|
return result, captured
|
||||||
|
|
||||||
|
def test_derived_checks_required_is_forwarded(self):
|
||||||
|
result, captured = self._run(
|
||||||
|
protections=[{"branch_name": "master", "enable_status_check": True,
|
||||||
|
"status_check_contexts": ["ci/build"]}],
|
||||||
|
status_payload={"state": "pending", "statuses": [
|
||||||
|
{"context": "ci/build", "status": "pending"}]},
|
||||||
|
)
|
||||||
|
self.assertIn("checks_required", captured)
|
||||||
|
self.assertTrue(captured["checks_required"])
|
||||||
|
self.assertEqual(captured["checks_status"], CHECKS_PENDING)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
|
||||||
|
def test_pr750_empty_status_with_no_protection_is_merge_ready(self):
|
||||||
|
# The exact reported failure: combined pending, zero contexts, and no
|
||||||
|
# branch protection requiring checks.
|
||||||
|
result, captured = self._run(
|
||||||
|
protections=[],
|
||||||
|
status_payload={"state": "pending", "statuses": []},
|
||||||
|
)
|
||||||
|
self.assertFalse(captured["checks_required"])
|
||||||
|
self.assertEqual(captured["checks_status"], CHECKS_NOT_REQUIRED)
|
||||||
|
self.assertNotEqual(result["checks_status"], CHECKS_PENDING)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||||
|
|
||||||
|
def test_protection_read_failure_blocks(self):
|
||||||
|
result, captured = self._run(
|
||||||
|
protections=RuntimeError("protection unavailable"),
|
||||||
|
status_payload={"state": "success", "statuses": []},
|
||||||
|
)
|
||||||
|
self.assertTrue(captured["checks_required"])
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
|
||||||
|
def test_caller_supplied_status_cannot_mask_live_required_failure(self):
|
||||||
|
# A caller-declared "success" must not override live evidence.
|
||||||
|
result, captured = self._run(
|
||||||
|
protections=[{"branch_name": "master", "enable_status_check": True,
|
||||||
|
"status_check_contexts": ["ci/build"]}],
|
||||||
|
status_payload={"state": "failure", "statuses": [
|
||||||
|
{"context": "ci/build", "status": "failure"}]},
|
||||||
|
caller_checks_status="success",
|
||||||
|
)
|
||||||
|
self.assertEqual(captured["checks_status"], CHECKS_FAILURE)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertEqual(
|
||||||
|
result["checks_evidence"]["caller_supplied_checks_status"], "success"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_checks_evidence_is_reported_without_secrets(self):
|
||||||
|
result, _ = self._run(
|
||||||
|
protections=[],
|
||||||
|
status_payload={"state": "pending", "statuses": []},
|
||||||
|
)
|
||||||
|
evidence = result["checks_evidence"]
|
||||||
|
self.assertEqual(evidence["context_count"], 0)
|
||||||
|
self.assertEqual(evidence["combined_state"], "pending")
|
||||||
|
self.assertFalse(evidence["protection_found"])
|
||||||
|
blob = repr(result).lower()
|
||||||
|
self.assertNotIn("authorization", blob)
|
||||||
|
self.assertNotIn("token", blob)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -79,22 +79,26 @@ class TestReviewerLeaseAcquire(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestReviewerLeaseFreshness(unittest.TestCase):
|
class TestReviewerLeaseFreshness(unittest.TestCase):
|
||||||
def test_stale_warning_after_30_minutes(self):
|
def test_stale_warning_at_half_the_sliding_window(self):
|
||||||
|
# #747 warns at half the 10-minute window, while the owner can still
|
||||||
|
# heartbeat and keep the lease.
|
||||||
lease = leases.parse_lease_comment(
|
lease = leases.parse_lease_comment(
|
||||||
_lease_comment(382, "session-a", minutes_ago=35)["body"]
|
_lease_comment(382, "session-a", minutes_ago=6)["body"]
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
leases.classify_lease_freshness(lease),
|
leases.classify_lease_freshness(lease),
|
||||||
"stale_warning",
|
"stale_warning",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_reclaimable_after_60_minutes(self):
|
def test_expired_once_the_sliding_window_lapses(self):
|
||||||
|
# Pre-#747 a 65-minute-idle lease was "reclaimable" and had to wait out
|
||||||
|
# a second timer. It is now simply expired and immediately takeable.
|
||||||
lease = leases.parse_lease_comment(
|
lease = leases.parse_lease_comment(
|
||||||
_lease_comment(382, "session-a", minutes_ago=65)["body"]
|
_lease_comment(382, "session-a", minutes_ago=65)["body"]
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
leases.classify_lease_freshness(lease),
|
leases.classify_lease_freshness(lease),
|
||||||
"reclaimable",
|
"expired",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -281,7 +285,10 @@ class TestReviewerLeaseHandoffDiagnose(unittest.TestCase):
|
|||||||
self.assertEqual(result["active_lease"]["comment_id"], 8647)
|
self.assertEqual(result["active_lease"]["comment_id"], 8647)
|
||||||
self.assertFalse(result["mutation_allowed"])
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
|
||||||
def test_foreign_reclaimable_release_expired(self):
|
def test_foreign_expired_release_expired(self):
|
||||||
|
# Pre-#747 this classified as "foreign_reclaimable" after the 60-minute
|
||||||
|
# activity band. Under the sliding TTL the lease is simply expired, and
|
||||||
|
# the sanctioned next action is unchanged.
|
||||||
reclaim = _lease_comment(
|
reclaim = _lease_comment(
|
||||||
592, "foreign-old", phase="claimed", minutes_ago=65
|
592, "foreign-old", phase="claimed", minutes_ago=65
|
||||||
)
|
)
|
||||||
@@ -293,7 +300,7 @@ class TestReviewerLeaseHandoffDiagnose(unittest.TestCase):
|
|||||||
current_reviewer_identity="sysadmin",
|
current_reviewer_identity="sysadmin",
|
||||||
proposed_worktree="branches/review-pr-592",
|
proposed_worktree="branches/review-pr-592",
|
||||||
)
|
)
|
||||||
self.assertEqual(result["classification"], "foreign_reclaimable")
|
self.assertEqual(result["classification"], "foreign_expired")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
result["next_action"], leases.NEXT_ACTION_RELEASE_EXPIRED_LEASE
|
result["next_action"], leases.NEXT_ACTION_RELEASE_EXPIRED_LEASE
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user