diff --git a/CHANGELOG.md b/CHANGELOG.md index c896d1ed..bf11a672 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -578,6 +578,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 is `gittuf`, so the MCP entry that `darnit install` writes gave clients the gittuf tools. With several frameworks installed and no baseline it now asks for `--framework` instead of guessing. +- The `gh_api` step no longer calls the platform when its endpoint needs + `$OWNER` or `$REPO` and the checkout has no repository identity (no git + remote). It used to substitute an empty owner, ask for `/repos//name`, + and report the 404 that came back; a step declaring 404 in + `fail_on_status` would have turned that into FAIL. It is now `ERROR`, + class `unavailable`, with a message that says why. - Installs without the development dependencies work again (`uv tool install` from a checkout, and any install from a built wheel). Two things broke them: `tree-sitter-language-pack` 1.6.3, the newest release the old diff --git a/packages/darnit/src/darnit/sieve/builtin_handlers.py b/packages/darnit/src/darnit/sieve/builtin_handlers.py index 8f66eb16..b41e0a6f 100644 --- a/packages/darnit/src/darnit/sieve/builtin_handlers.py +++ b/packages/darnit/src/darnit/sieve/builtin_handlers.py @@ -554,6 +554,23 @@ def gh_api_handler(config: dict[str, Any], context: HandlerContext) -> HandlerRe message="No endpoint specified for gh_api handler", error_class="evaluation", ) + # A checkout with no remote has no owner (detect_owner_repo returns ""). + # Substituting it would ask the platform about "/repos//name", and the 404 + # that comes back says nothing about this repository. It must not be + # reported as a platform answer, and never as a declared failure. + missing = [var for var, val in (("$OWNER", context.owner), ("$REPO", context.repo)) if var in endpoint and not val] + if missing: + message = ( + f"Platform API call {endpoint} needs {' and '.join(missing)}, and this checkout has no " + "repository identity (no git remote was found)" + ) + _log_environmental_failure(context.control_id, "gh_api", "unavailable", message) + return HandlerResult( + status=HandlerResultStatus.ERROR, + message=message, + evidence={"endpoint": endpoint}, + error_class="unavailable", + ) for var, val in (("$OWNER", context.owner), ("$REPO", context.repo), ("$BRANCH", context.default_branch)): endpoint = endpoint.replace(var, val or "") diff --git a/tests/darnit/sieve/test_gh_api_handler.py b/tests/darnit/sieve/test_gh_api_handler.py index 24961ce7..e69dd25b 100644 --- a/tests/darnit/sieve/test_gh_api_handler.py +++ b/tests/darnit/sieve/test_gh_api_handler.py @@ -234,3 +234,34 @@ def test_expr_evaluated_once_by_handler(self, recorded, caplog) -> None: ) assert result.status == "PASS" assert not [r for r in caplog.records if "CEL evaluation failed" in r.getMessage()] + + +class TestMissingRepositoryIdentity: + """A checkout with no remote has no owner; the platform is not asked about "/repos//name".""" + + @staticmethod + def _run_without_owner(config: dict) -> object: + ctx = HandlerContext(local_path="/tmp", owner="", repo="hello", default_branch="main", control_id="GH-01") + return gh_api_handler({**STEP, **config}, ctx) + + def test_no_owner_is_error_and_makes_no_call(self, recorded) -> None: + responder = recorded({}) + result = self._run_without_owner({}) + assert responder.calls == [] + assert result.status == HandlerResultStatus.ERROR + assert result.error_class == "unavailable" + assert "$OWNER" in result.message + assert result.evidence == {"endpoint": STEP["endpoint"]} + + def test_no_owner_never_proves_a_declared_failure(self, recorded) -> None: + responder = recorded({"/repos//hello/branches/main/protection": {"status": 404, "error": "HTTP 404: Not Found"}}) + result = self._run_without_owner({"fail_on_status": [404]}) + assert responder.calls == [] + assert result.status == HandlerResultStatus.ERROR + + def test_endpoint_that_needs_no_identity_still_runs(self, recorded) -> None: + responder = recorded({"/user": {"status": 200, "body": {}}}) + ctx = HandlerContext(local_path="/tmp", owner="", repo="", default_branch="main", control_id="GH-01") + result = gh_api_handler({"handler": "gh_api", "endpoint": "/user"}, ctx) + assert responder.calls == ["/user"] + assert result.status == HandlerResultStatus.PASS