feat(controller): Phase 4 metadata-hygiene + round-2 adversarial fixes

Five deterministic, idempotent Phase 4 checks:
  1. completed_not_closed — close linked issues on MERGED
  2. closing_keyword_fixup — add Closes #N to PR bodies
  3. label_sync_from_issue — copy Priority/Type/MoSCoW labels
  4. state_label_inference — sync State/* label to current_state
  5. milestone_assignment — copy milestone from linked issue

All default-off via CONTROLLER_METADATA_HYGIENE_ENABLED +
per-check granular env flags. Dry-run mode shares the grooming
CONTROLLER_GROOMING_DRY_RUN flag.

Round-1 fixes (applied before this commit):
- False-positive idempotency lock (executed=True on skip)
- Unbounded MERGED scan → LEFT JOIN candidate query
- Duplicate _classify_forgejo_status → import from forgejo_writes
- Bare-ref regex too broad ([#42](url) misread) → add [ lookbehind
- Wrong audit stage → 'metadata_hygiene'

Round-2 adversarial fixes (3 architect, 4 principal, 7 test engineer):
- completed_not_closed: executed=True only when ALL refs close;
  partial success writes executed=False so remaining issues retry
- milestone_assignment: was calling get_pr_details (hits /pulls/,
  returns 404 for plain issues) → now uses get_issue_state
  (/issues/{n}) so milestone fetch works for all issue types
- label_sync failure path: write executed=False audit row for
  observability; pre-fix left no audit trail for persistent failures
- _BARE_REF_RE: add ( to lookbehind to exclude (#42) link destinations
- state_label_inference: re-read current_state inside inner session to
  avoid stale-snapshot spurious label writes across session boundaries
- _last_synced_state: add decision_id DESC tiebreaker for same-second
  wall-clock rows
- dry-run completed_not_closed: separate early-return path to avoid
  inflating completed_not_closed_executed counter

71 tests (54 round-1 + 17 round-2):
- TestCompletedNotClosedPartialSuccess (3) — partial/zero/full success
- TestLabelSyncAdjustLabelsFailure (2) — failure audit + retry
- TestStateLabelAdjustLabelsFailure (2) — no executed=1 on failure
- TestStateLabelInferenceTerminalWorkflows (3) — MERGED/ABANDONED sync
- TestLastSyncedStateDryRunThenReal (2) — dry-run → real-run
- TestClosingKeywordFixupBareRefAlreadyCovered (2) — candidates subtraction
- TestErrorPathHandlingRound2 (3) — label_sync + state_label errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 17:41:49 -04:00
parent 4d969eaf2b
commit a21466add2
6 changed files with 2965 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+47
View File
@@ -39,6 +39,11 @@ from .gate3_abandon_config import (
)
from .grooming_side_effects import GroomingCallbacks
from .loop import MasterConfig, master_main_loop
from .metadata_hygiene import MetadataHygieneCallbacks
from .metadata_hygiene_config import (
get_metadata_hygiene_config as _get_metadata_hygiene_cfg,
log_effective_config as _log_metadata_hygiene_cfg,
)
from .reviewer_abandon_side_effects import ReviewerAbandonCallbacks
from .prefetch import PrefetchDataCallbacks, make_prefetch_callback
@@ -282,6 +287,11 @@ def main(argv: list[str] | None = None) -> int:
# incident happened?".
_log_gate3_abandon_cfg()
_gate3_abandon_cfg = _get_gate3_abandon_cfg()
# Phase 4 (metadata-hygiene, 2026-05-25): log + load the config.
# Same startup-forensics rationale — operators get a log-grep
# anchor for "what was set when this incident happened?".
_log_metadata_hygiene_cfg()
_metadata_hygiene_cfg = _get_metadata_hygiene_cfg()
# Phase 1k++ (N6): parser-coverage check runs BEFORE backfill +
# main loop so strict-mode failure exits 2 without wasting a
@@ -500,6 +510,43 @@ def main(argv: list[str] | None = None) -> int:
if _gate3_abandon_cfg.enabled
else None
),
# Phase 4 (2026-05-25): metadata-hygiene tick. Gated on the
# master CONTROLLER_METADATA_HYGIENE_ENABLED flag (default
# False) — each of the five sub-checks has its own per-check
# flag. ``dry_run`` shares the grooming flag for symmetry with
# Gates 1/2/3 (one safe-rollout toggle covers all metadata
# write paths).
metadata_hygiene_callbacks=(
MetadataHygieneCallbacks(
get_pr_details=callbacks.get_pr_details,
# get_issue_state hits /issues/{n} — needed by check #5
# to read an issue's milestone. Using get_pr_details
# (/pulls/{n}) would 404 on plain issues. (Round-2 fix.)
get_issue_state=callbacks.get_issue_state,
get_labels=callbacks.get_labels,
add_label=callbacks.add_label,
remove_label=callbacks.remove_label,
patch_pr_state=callbacks.patch_pr_state,
patch_pr_body=callbacks.patch_pr_body,
patch_pr_milestone=callbacks.patch_pr_milestone,
completed_not_closed_enabled=(
_metadata_hygiene_cfg.completed_not_closed
),
closing_keyword_fixup_enabled=(
_metadata_hygiene_cfg.closing_keyword_fixup
),
label_sync_enabled=_metadata_hygiene_cfg.label_sync_from_issue,
state_label_inference_enabled=(
_metadata_hygiene_cfg.state_label_inference
),
milestone_assignment_enabled=(
_metadata_hygiene_cfg.milestone_assignment
),
dry_run=_grooming_cfg.dry_run,
)
if _metadata_hygiene_cfg.enabled
else None
),
# RUN_CI_LOCAL: skip ci_poll_exhaustion while local CI is busy
# (None — a no-op — under remote CI).
local_ci_in_flight=local_ci_in_flight,
+68
View File
@@ -61,6 +61,19 @@ GetCIStatusCallback = _Callable[[str, str, str], dict | None]
# text for the failing jobs (empty string when none / unreachable).
GetFailureLogsCallback = _Callable[[str, str, str], str]
# Phase 4 metadata-hygiene PATCH callbacks (2026-05-25). Both PATCH
# Forgejo's unified issues endpoint:
# PATCH /repos/{owner}/{repo}/issues/{n} body={"<field>": <value>}
# Return the raw ``{"status": int, "body": ...}`` shape so the per-
# check orchestrator can dispatch on the error-handling matrix (200 =
# success, 404 = treat as no-op, 422 = stuck, 5xx/429 = retry).
# patch_pr_body(owner, repo, pr_number, body) → dict
PatchPRBodyCallback = _Callable[[str, str, int, str], dict]
# patch_pr_milestone(owner, repo, pr_number, milestone_id) → dict
# ``milestone_id`` may be ``None`` to clear the assignment, or an int
# to set it.
PatchPRMilestoneCallback = _Callable[[str, str, int, "int | None"], dict]
@dataclass
class ForgejoCallbacks:
@@ -78,6 +91,12 @@ class ForgejoCallbacks:
# PATCH /issues/{n} {"state": ...} — used by grooming's close path
# (Phase 0 grooming plan; orchestration lives in forgejo_writes.close_issue).
patch_pr_state: fw.PatchPRStateCallback
# PATCH /issues/{n} {"body": ...} — Phase 4 metadata-hygiene
# (closing-keyword fixup adds ``Closes #N`` to the PR body).
patch_pr_body: "PatchPRBodyCallback"
# PATCH /issues/{n} {"milestone": ...} — Phase 4 metadata-hygiene
# (milestone assignment copies milestone from linked issue).
patch_pr_milestone: "PatchPRMilestoneCallback"
merge_pr: mg.MergeCallback
# Reconciliation callbacks (Phase 1g):
get_pr_state: rec.GetPRStateCallback
@@ -136,6 +155,8 @@ def build_callbacks(
add_label=_make_add_label(cfg, runtime),
remove_label=_make_remove_label(cfg, runtime),
patch_pr_state=_make_patch_pr_state(cfg, runtime),
patch_pr_body=_make_patch_pr_body(cfg, runtime),
patch_pr_milestone=_make_patch_pr_milestone(cfg, runtime),
merge_pr=_make_merge_pr(cfg, runtime),
get_pr_state=_make_get_pr_state(cfg, runtime),
get_issue_state=_make_get_issue_state(cfg, runtime),
@@ -296,6 +317,53 @@ def _make_patch_pr_state(cfg, runtime):
return patch_pr_state
def _make_patch_pr_body(cfg, runtime):
"""Build the PATCH-PR-body closure (Phase 4 metadata-hygiene).
Forgejo's unified issues endpoint accepts ``body`` mutations for
both issues and PRs:
PATCH /repos/{owner}/{repo}/issues/{n} body={"body": "..."}
Used by the closing-keyword-fixup tick to add ``Closes #N`` to a
PR body that references issue N without the closing keyword.
"""
def patch_pr_body(
owner: str,
repo: str,
pr_number: int,
body: str,
) -> dict:
path = f"/repos/{owner}/{repo}/issues/{int(pr_number)}"
return runtime.patch(path, cfg, {"body": body})
return patch_pr_body
def _make_patch_pr_milestone(cfg, runtime):
"""Build the PATCH-PR-milestone closure (Phase 4 metadata-hygiene).
Forgejo's unified issues endpoint accepts ``milestone`` mutations
for both issues and PRs:
PATCH /repos/{owner}/{repo}/issues/{n} body={"milestone": <id>}
Pass ``None`` to clear, int milestone-id to set. Used by the
milestone-assignment tick to copy the milestone from a linked
issue onto its PR when the PR has none.
"""
def patch_pr_milestone(
owner: str,
repo: str,
pr_number: int,
milestone_id: int | None,
) -> dict:
path = f"/repos/{owner}/{repo}/issues/{int(pr_number)}"
return runtime.patch(path, cfg, {"milestone": milestone_id})
return patch_pr_milestone
def _make_remove_label(cfg, runtime):
def remove_label(
owner: str,
+31
View File
@@ -58,6 +58,11 @@ from .grooming_side_effects import (
run_grooming_side_effects_tick,
)
from .merging import MergeCallback, MergingHandlerReport, run_merging_tick
from .metadata_hygiene import (
MetadataHygieneCallbacks,
MetadataHygieneReport,
run_metadata_hygiene_tick,
)
from .reviewer_abandon_side_effects import (
ReviewerAbandonCallbacks,
ReviewerAbandonSideEffectReport,
@@ -237,6 +242,13 @@ def master_main_loop(
# and performs the Forgejo close via ``forgejo_writes.close_act``
# with ``cause=Cause.REVIEWER_ABANDON``. Same shape + dry_run
# source as the estimator-abandon tick — Phase 3 (2026-05-25).
metadata_hygiene_callbacks: MetadataHygieneCallbacks | None = None,
# Phase 4 (2026-05-25): when set, the metadata-hygiene tick runs
# every iteration; it invokes each enabled check (completed-not-
# closed, closing-keyword fixup, label sync, state-label inference,
# milestone assignment). Each check is independently gated via
# per-check flags on the callbacks dataclass. None disables the
# entire phase regardless of per-check flags.
grooming_callbacks: GroomingCallbacks | None = None,
# When set, the grooming side-effect tick fires every iteration:
# it finds workflows whose state-machine just transitioned via
@@ -474,6 +486,25 @@ def master_main_loop(
"reviewer_abandon_side_effects tick raised; continuing"
)
# Phase 4 (2026-05-25) — metadata-hygiene dispatcher.
# Runs every iteration; each of the five checks
# (completed-not-closed, closing-keyword fixup, label sync,
# state-label inference, milestone assignment) is gated by
# its own per-check flag on ``metadata_hygiene_callbacks``.
# ``None`` disables the whole phase regardless of per-check
# flags. Cheap when no candidates exist per check.
metadata_hygiene_report: MetadataHygieneReport | None = None
if metadata_hygiene_callbacks is not None:
try:
metadata_hygiene_report = run_metadata_hygiene_tick(
engine=engine,
callbacks=metadata_hygiene_callbacks,
)
except Exception:
logger.exception(
"metadata_hygiene tick raised; continuing"
)
# Phase 1k+++ (real-run): MERGING handler. For workflows
# in MERGING state, call the Forgejo merge endpoint via
# the injected callback. Without this, workflows that
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,124 @@
"""Phase 4 — metadata-hygiene configuration.
Phase 4 adds five deterministic, idempotent metadata-hygiene checks
the controller can perform on Forgejo PRs / issues. Each check is
behind an INDIVIDUAL feature flag (so operators can roll out one at
a time + revert a problem check without disabling the whole phase)
PLUS a master enable flag (so operators can disable all five during
an incident without touching individual flags).
Standard rollout sequence per check:
- master ENABLED=false (default) none of the five run; zero risk.
- master ENABLED=true + per-check ENABLED=false + DRY_RUN=true the
tick computes what it WOULD do, writes an audit row with
``executed=0``, and emits a log line. No Forgejo writes.
- master ENABLED=true + per-check ENABLED=true + DRY_RUN=false
full path active.
The kill switches matter because each check writes to Forgejo:
- completed-not-closed: closes a (likely operator-watched) issue
- closing-keyword fixup: mutates the PR body
- label sync: adds labels (operator-visible)
- state-label inference: adds/removes labels on every iteration
- milestone assignment: assigns a milestone
A mis-fire of any of these is operator-visible noise at best,
operator-visible damage at worst. Default-off + per-check granular
control is the right safety posture.
Pairs with ``CONTROLLER_GROOMING_DRY_RUN`` (which metadata-hygiene
shares as its safe-rollout layer for symmetry with Gate 1/2/3) +
this module's own ``CONTROLLER_METADATA_HYGIENE_*`` toggles.
"""
from __future__ import annotations
import json
import logging
import os
from dataclasses import asdict, dataclass
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class MetadataHygieneConfig:
"""Frozen config for the Phase 4 metadata-hygiene checks.
Each flag defaults to False so a fresh deploy of Phase 4 code is
audit-only until the operator explicitly enables each path. The
master ``enabled`` flag is the kill switch when False, the loop
skips the entire metadata-hygiene block regardless of per-check
flags.
"""
# Master switch. When False, ``__main__.py`` passes
# ``metadata_hygiene_callbacks=None`` to ``master_main_loop``;
# all five checks are skipped.
enabled: bool = False
# Per-check feature flags. Each can be flipped independently of
# the others (e.g. ship label-sync first, hold the closing-
# keyword-fixup until operators have watched a few days of audit
# rows).
completed_not_closed: bool = False
closing_keyword_fixup: bool = False
label_sync_from_issue: bool = False
state_label_inference: bool = False
milestone_assignment: bool = False
def _bool(env_name: str, default: bool) -> bool:
raw = os.environ.get(env_name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def get_metadata_hygiene_config() -> MetadataHygieneConfig:
"""Read the effective Phase 4 config from environment variables.
Pure function; safe to call repeatedly. Operators typically log
the result via ``log_effective_config`` at controller startup so
incident-response can recover "what was set when this happened?"
from the log stream.
"""
return MetadataHygieneConfig(
enabled=_bool("CONTROLLER_METADATA_HYGIENE_ENABLED", False),
completed_not_closed=_bool(
"CONTROLLER_METADATA_HYGIENE_COMPLETED_NOT_CLOSED", False
),
closing_keyword_fixup=_bool(
"CONTROLLER_METADATA_HYGIENE_CLOSING_KEYWORD_FIXUP", False
),
label_sync_from_issue=_bool(
"CONTROLLER_METADATA_HYGIENE_LABEL_SYNC", False
),
state_label_inference=_bool(
"CONTROLLER_METADATA_HYGIENE_STATE_LABEL_INFERENCE", False
),
milestone_assignment=_bool(
"CONTROLLER_METADATA_HYGIENE_MILESTONE_ASSIGNMENT", False
),
)
def log_effective_config() -> None:
"""Emit one INFO-level line with the effective Phase 4 config.
Called from controller startup so operators have a log-grep anchor
for "what was set when this incident happened?" without needing to
reconstruct env-var state from systemd / shell history.
"""
cfg = get_metadata_hygiene_config()
logger.info(
"metadata_hygiene config: %s",
json.dumps(asdict(cfg), sort_keys=True),
)
__all__ = [
"MetadataHygieneConfig",
"get_metadata_hygiene_config",
"log_effective_config",
]