diff --git a/ariadne/services/hermes_code_defects.py b/ariadne/services/hermes_code_defects.py index d52896f..d1d6fb3 100644 --- a/ariadne/services/hermes_code_defects.py +++ b/ariadne/services/hermes_code_defects.py @@ -241,20 +241,47 @@ def _is_writable(path: str, cfg: dict) -> bool: return bool(prefixes) and any(path.startswith(p) for p in prefixes) -def extract_defects(bundle: dict, cfg: dict) -> list[dict[str, Any]]: - """Return every mechanically-fixable defect the evidence names. +ALL_CATEGORIES: tuple[str, ...] = (LINT_VIOLATION, UNDEFINED_NAME, FAILING_ASSERTION) - Inputs: the evidence bundle and the per-job code cfg. Outputs: the lint, - undefined-name and failing-assertion defects together, most precisely - located first, so the instruction leads with the defect whose fix is least - open to interpretation. + +def enabled_categories(cfg: dict) -> tuple[str, ...]: + """Return the defect categories this deployment allows Hermes to be asked about. + + Inputs: the per-job code cfg, whose `fix_categories` names the enabled + ones. Outputs: the recognised subset, in declaration order. + + This is the operator's control over what Hermes may be pointed at. It is + deliberately separate from the action allowlist: that gates what Ariadne + executes on its own, whereas everything here becomes a pull request a + person reads. An unrecognised name is ignored rather than trusted, and an + empty setting means every category, so the default stays obvious. """ - return ( - extract_lint_defects(bundle, cfg) - + extract_name_defects(bundle, cfg) - + extract_assertion_defects(bundle, cfg) - ) + raw = cfg.get("fix_categories") if isinstance(cfg, dict) else None + names = {str(item).strip() for item in (raw or []) if str(item).strip()} + if not names: + return ALL_CATEGORIES + return tuple(category for category in ALL_CATEGORIES if category in names) + + +def extract_defects(bundle: dict, cfg: dict) -> list[dict[str, Any]]: + """Return every enabled mechanically-fixable defect the evidence names. + + Inputs: the evidence bundle and the per-job code cfg. Outputs: the enabled + categories' defects together, most precisely located first, so the + instruction leads with the defect whose fix is least open to + interpretation. + """ + + enabled = enabled_categories(cfg) + found: list[dict[str, Any]] = [] + if LINT_VIOLATION in enabled: + found += extract_lint_defects(bundle, cfg) + if UNDEFINED_NAME in enabled: + found += extract_name_defects(bundle, cfg) + if FAILING_ASSERTION in enabled: + found += extract_assertion_defects(bundle, cfg) + return found def defect_instruction(defects: list[dict[str, Any]]) -> str: diff --git a/ariadne/services/hermes_code_repos.py b/ariadne/services/hermes_code_repos.py index 98c2e04..0f9be48 100644 --- a/ariadne/services/hermes_code_repos.py +++ b/ariadne/services/hermes_code_repos.py @@ -48,6 +48,7 @@ def build_config(config: Any) -> dict[str, Any]: "owner": config.hermes_code_owner, "repo": config.hermes_code_repo, "max_open_proposals": getattr(config, "hermes_code_max_open_proposals", 0), + "fix_categories": list(getattr(config, "hermes_fix_categories", None) or []), "base_branch": config.hermes_code_base_branch, "timeout_seconds": _GITEA_TIMEOUT_SECONDS, "legacy_job": str(getattr(config, "hermes_code_job", "") or ""), diff --git a/ariadne/settings.py b/ariadne/settings.py index ca649aa..75f2bf3 100644 --- a/ariadne/settings.py +++ b/ariadne/settings.py @@ -193,6 +193,7 @@ class Settings: hermes_max_branches: int hermes_job_namespaces: dict hermes_code_max_open_proposals: int + hermes_fix_categories: list hermes_max_actions_per_incident: int hermes_api_url: str hermes_api_key: str diff --git a/ariadne/settings_hermes.py b/ariadne/settings_hermes.py index 0f83f78..be63138 100644 --- a/ariadne/settings_hermes.py +++ b/ariadne/settings_hermes.py @@ -41,6 +41,9 @@ def _hermes_autotriage_config() -> dict[str, Any]: "hermes_max_branches": _env_int("ARIADNE_HERMES_MAX_BRANCHES", 5), "hermes_job_namespaces": _pair_map(_env("ARIADNE_HERMES_JOB_NAMESPACES", "")), "hermes_code_max_open_proposals": _env_int("ARIADNE_HERMES_CODE_MAX_OPEN_PROPOSALS", 64), + "hermes_fix_categories": [ + item.strip() for item in _env("ARIADNE_HERMES_FIX_CATEGORIES", "").split(",") if item.strip() + ], "hermes_max_actions_per_incident": _env_int("ARIADNE_HERMES_MAX_ACTIONS_PER_INCIDENT", 1), "hermes_api_url": _env( "ARIADNE_HERMES_API_URL", diff --git a/tests/test_hermes_code_defects.py b/tests/test_hermes_code_defects.py index 7a50071..e97f6bf 100644 --- a/tests/test_hermes_code_defects.py +++ b/tests/test_hermes_code_defects.py @@ -271,3 +271,39 @@ def test_a_lint_defect_with_a_disallowed_suffix_is_dropped() -> None: cfg = {"allowed_path_prefixes": ["ariadne/"], "allowed_suffixes": [".go"]} assert module.extract_lint_defects(_bundle("ariadne/app.py:1:1: E501 Long"), cfg) == [] + + +# --- the operator's control over what Hermes may be asked to fix --------------- + + +def test_no_setting_means_every_category() -> None: + """The default must be obvious rather than silently empty.""" + + assert module.enabled_categories({}) == module.ALL_CATEGORIES + assert module.enabled_categories({"fix_categories": []}) == module.ALL_CATEGORIES + assert module.enabled_categories(None) == module.ALL_CATEGORIES + + +def test_a_category_can_be_switched_off() -> None: + cfg = {**CFG, "fix_categories": [module.LINT_VIOLATION]} + assert module.enabled_categories(cfg) == (module.LINT_VIOLATION,) + + bundle = { + "jenkins": { + "console_failures": [{"text": "ariadne/app.py:12:5: E501 Line too long"}], + "console_tail": "", + "failed_tests": [{"name": "t", "className": "c", "errorDetails": "boom"}], + } + } + cats = {d["category"] for d in module.extract_defects(bundle, cfg)} + assert cats == {module.LINT_VIOLATION} + + +def test_an_unrecognised_category_is_ignored_not_trusted() -> None: + cfg = {"fix_categories": ["lint_violation", "rewrite_everything"]} + assert module.enabled_categories(cfg) == (module.LINT_VIOLATION,) + + +def test_categories_keep_their_declared_order() -> None: + cfg = {"fix_categories": [module.FAILING_ASSERTION, module.LINT_VIOLATION]} + assert module.enabled_categories(cfg) == (module.LINT_VIOLATION, module.FAILING_ASSERTION) diff --git a/tests/test_hermes_code_flow.py b/tests/test_hermes_code_flow.py index dbd8259..454768f 100644 --- a/tests/test_hermes_code_flow.py +++ b/tests/test_hermes_code_flow.py @@ -46,6 +46,7 @@ def _code_cfg(**overrides) -> dict: # type: ignore[no-untyped-def] "owner": "bstein", "repo": "hermes-code-demo", "max_open_proposals": 0, + "fix_categories": [], "base_branch": "master", "timeout_seconds": 15.0, "legacy_job": JOB,