Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a028de-e2c8-7732-8521-46a7fc5db82f
308 lines
10 KiB
Python
308 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from activity_core import activities
|
|
from activity_core.issue_sink import IssueCoreRestSink
|
|
from activity_core.rules.models import TaskRef, TaskSpec
|
|
|
|
|
|
class DummyResponse:
|
|
def __init__(self, payload: dict[str, Any], status_code: int = 200) -> None:
|
|
self.payload = payload
|
|
self.status_code = status_code
|
|
self.text = ""
|
|
|
|
def raise_for_status(self) -> None:
|
|
if self.status_code >= 400:
|
|
raise httpx.HTTPStatusError(
|
|
"error",
|
|
request=httpx.Request("POST", "http://issue-core.test/issues/"),
|
|
response=httpx.Response(self.status_code),
|
|
)
|
|
|
|
def json(self) -> dict[str, Any]:
|
|
return self.payload
|
|
|
|
|
|
def test_issue_core_rest_sink_posts_task_contract(monkeypatch) -> None:
|
|
posts: list[dict[str, Any]] = []
|
|
|
|
def fake_post(url: str, **kwargs: Any) -> DummyResponse:
|
|
posts.append({"url": url, **kwargs})
|
|
return DummyResponse({
|
|
"issue_id": "issue-123",
|
|
"issue_url": "http://issue-core.test/issues/issue-123",
|
|
"backend": "issue-core",
|
|
})
|
|
|
|
monkeypatch.setattr(httpx, "post", fake_post)
|
|
|
|
ref = IssueCoreRestSink("http://issue-core.test/", api_key="test-key").emit(TaskSpec(
|
|
title="Run SBOM rescan for activity-core",
|
|
description="SBOM is older than 30 days.",
|
|
target_repo="activity-core",
|
|
priority="medium",
|
|
labels=["sbom", "security", "automated"],
|
|
due_in_days=7,
|
|
source_type="rule",
|
|
source_id="flag-stale-sbom",
|
|
triggering_event_id="scheduled",
|
|
activity_definition_id="activity-1",
|
|
))
|
|
|
|
assert ref == TaskRef(
|
|
external_id="issue-123",
|
|
backend_url="http://issue-core.test/issues/issue-123",
|
|
backend="issue-core",
|
|
)
|
|
assert posts == [
|
|
{
|
|
"url": "http://issue-core.test/issues/",
|
|
"json": {
|
|
"title": "Run SBOM rescan for activity-core",
|
|
"description": "SBOM is older than 30 days.",
|
|
"target_repo": "activity-core",
|
|
"priority": "medium",
|
|
"labels": ["sbom", "security", "automated"],
|
|
"due_in_days": 7,
|
|
"source_type": "rule",
|
|
"source_id": "flag-stale-sbom",
|
|
"triggering_event_id": "scheduled",
|
|
"activity_definition_id": "activity-1",
|
|
},
|
|
"headers": {"Authorization": "Bearer test-key"},
|
|
"timeout": 10.0,
|
|
}
|
|
]
|
|
assert "review_required" not in posts[0]["json"]
|
|
|
|
|
|
def test_default_sink_is_state_hub_not_rest(monkeypatch) -> None:
|
|
"""ACTIVITY-WP-0022: unset ISSUE_SINK_TYPE must not open Forgejo/rest."""
|
|
from activity_core import issue_sink as mod
|
|
|
|
monkeypatch.delenv("ISSUE_SINK_TYPE", raising=False)
|
|
sink = mod.get_issue_sink()
|
|
assert type(sink).__name__ == "StateHubProgressSink"
|
|
|
|
|
|
def test_unknown_sink_type_falls_back_to_state_hub(monkeypatch) -> None:
|
|
from activity_core import issue_sink as mod
|
|
|
|
monkeypatch.setenv("ISSUE_SINK_TYPE", "forgejo-please")
|
|
sink = mod.get_issue_sink()
|
|
assert type(sink).__name__ == "StateHubProgressSink"
|
|
|
|
|
|
def test_rest_sink_still_available_when_explicit(monkeypatch) -> None:
|
|
from activity_core import issue_sink as mod
|
|
|
|
monkeypatch.setenv("ISSUE_SINK_TYPE", "rest")
|
|
sink = mod.get_issue_sink()
|
|
assert type(sink).__name__ == "IssueCoreRestSink"
|
|
|
|
|
|
def test_issue_core_rest_sink_requires_api_key() -> None:
|
|
sink = IssueCoreRestSink("http://issue-core.test/", api_key="")
|
|
with pytest.raises(RuntimeError, match="ISSUE_CORE_API_KEY"):
|
|
sink.emit(TaskSpec(
|
|
title="t",
|
|
description="",
|
|
target_repo="activity-core",
|
|
priority="low",
|
|
labels=[],
|
|
due_in_days=None,
|
|
source_type="rule",
|
|
source_id="r",
|
|
triggering_event_id="e",
|
|
activity_definition_id="a",
|
|
))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_emit_tasks_raises_when_sink_fails(monkeypatch) -> None:
|
|
class FailingSink:
|
|
def emit(self, task_spec: TaskSpec) -> TaskRef:
|
|
raise RuntimeError(f"boom for {task_spec.title}")
|
|
|
|
class FakeTransaction:
|
|
async def __aenter__(self) -> None:
|
|
return None
|
|
|
|
async def __aexit__(self, *exc_info: object) -> bool:
|
|
return False
|
|
|
|
class FakeSession:
|
|
def begin(self) -> FakeTransaction:
|
|
return FakeTransaction()
|
|
|
|
async def __aenter__(self) -> "FakeSession":
|
|
return self
|
|
|
|
async def __aexit__(self, *exc_info: object) -> bool:
|
|
return False
|
|
|
|
def add(self, row: object) -> None:
|
|
raise AssertionError("failed emissions should not write spawn logs")
|
|
|
|
class FakeSessionFactory:
|
|
def __call__(self) -> FakeSession:
|
|
return FakeSession()
|
|
|
|
monkeypatch.setattr(activities, "get_issue_sink", lambda: FailingSink())
|
|
monkeypatch.setattr(activities, "_get_session_factory", lambda: FakeSessionFactory())
|
|
|
|
with pytest.raises(RuntimeError, match="task emission sink failure"):
|
|
await activities.emit_tasks({
|
|
"activity_id": "00000000-0000-0000-0000-000000000001",
|
|
"triggering_event_id": "scheduled",
|
|
"run_id": "00000000-0000-0000-0000-000000000002",
|
|
"task_specs": [
|
|
{
|
|
"title": "Run SBOM rescan for activity-core",
|
|
"description": "",
|
|
"target_repo": "activity-core",
|
|
"priority": "medium",
|
|
"labels": ["sbom"],
|
|
"due_in_days": None,
|
|
"source_type": "rule",
|
|
"source_id": "flag-stale-sbom",
|
|
"condition": "context.repo.sbom_age_days > 30",
|
|
}
|
|
],
|
|
})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_emit_tasks_refuses_malformed_profile_before_any_side_effect(
|
|
monkeypatch,
|
|
) -> None:
|
|
from temporalio.exceptions import ApplicationError
|
|
|
|
monkeypatch.setattr(
|
|
activities,
|
|
"get_issue_sink",
|
|
lambda: pytest.fail("IssueSink must not be opened after profile refusal"),
|
|
)
|
|
monkeypatch.setattr(
|
|
activities,
|
|
"_get_session_factory",
|
|
lambda: pytest.fail("DB must not be opened after profile refusal"),
|
|
)
|
|
|
|
with pytest.raises(ApplicationError, match="must pin a version") as exc_info:
|
|
await activities.emit_tasks(
|
|
{
|
|
"activity_id": "00000000-0000-0000-0000-000000000001",
|
|
"triggering_event_id": "scheduled",
|
|
"task_specs": [
|
|
{
|
|
"title": "Must not emit",
|
|
"source_type": "rule",
|
|
"source_id": "malformed-profile",
|
|
"harness_profile_ref": "harness.agent-dev",
|
|
"approach_hint": "legacy-must-not-rescue",
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
assert exc_info.value.non_retryable is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_emit_tasks_refuses_absent_profile_in_strict_mode(monkeypatch) -> None:
|
|
from temporalio.exceptions import ApplicationError
|
|
|
|
monkeypatch.setenv("ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE", "true")
|
|
monkeypatch.setattr(
|
|
activities,
|
|
"get_issue_sink",
|
|
lambda: pytest.fail("IssueSink must not be opened after profile refusal"),
|
|
)
|
|
|
|
with pytest.raises(ApplicationError, match="cannot substitute"):
|
|
await activities.emit_tasks(
|
|
{
|
|
"activity_id": "00000000-0000-0000-0000-000000000001",
|
|
"triggering_event_id": "scheduled",
|
|
"task_specs": [
|
|
{
|
|
"title": "Must not emit",
|
|
"source_type": "rule",
|
|
"source_id": "missing-profile",
|
|
"approach_hint": "legacy-must-not-rescue",
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_emit_tasks_passes_unknown_versioned_profile_to_queue(monkeypatch) -> None:
|
|
captured: dict[str, Any] = {}
|
|
|
|
class SuccessfulSink:
|
|
def emit(self, task_spec: TaskSpec) -> TaskRef:
|
|
return TaskRef(external_id="state-hub:progress-1", backend="state-hub")
|
|
|
|
class FakeTransaction:
|
|
async def __aenter__(self) -> None:
|
|
return None
|
|
|
|
async def __aexit__(self, *exc_info: object) -> bool:
|
|
return False
|
|
|
|
class FakeSession:
|
|
def begin(self) -> FakeTransaction:
|
|
return FakeTransaction()
|
|
|
|
async def __aenter__(self) -> "FakeSession":
|
|
return self
|
|
|
|
async def __aexit__(self, *exc_info: object) -> bool:
|
|
return False
|
|
|
|
def add(self, row: object) -> None:
|
|
return None
|
|
|
|
class FakeSessionFactory:
|
|
def __call__(self) -> FakeSession:
|
|
return FakeSession()
|
|
|
|
async def fake_create_ops_run(session, spec, **kwargs):
|
|
captured.update(kwargs)
|
|
return None
|
|
|
|
monkeypatch.setattr(activities, "get_issue_sink", lambda: SuccessfulSink())
|
|
monkeypatch.setattr(activities, "_get_session_factory", lambda: FakeSessionFactory())
|
|
monkeypatch.setattr(activities, "create_ops_run_from_spec", fake_create_ops_run)
|
|
|
|
refs = await activities.emit_tasks(
|
|
{
|
|
"activity_id": "00000000-0000-0000-0000-000000000001",
|
|
"triggering_event_id": "event-1",
|
|
"task_specs": [
|
|
{
|
|
"title": "Let Glas resolve it",
|
|
"source_type": "rule",
|
|
"source_id": "profiled",
|
|
"harness_profile_ref": "harness.unknown-locally@9.9.9",
|
|
"approach_hint": "legacy-only",
|
|
"execution_refs": {
|
|
"correlation_id": "corr-9",
|
|
"api_key": "must-be-dropped",
|
|
},
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
assert refs == ["state-hub:progress-1"]
|
|
assert captured["harness_profile_ref"] == "harness.unknown-locally@9.9.9"
|
|
assert captured["approach_hint"] == "legacy-only"
|
|
assert captured["execution_refs"] == {"correlation_id": "corr-9"}
|