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:
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user