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
+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