diff --git a/backend/druks/contrib/software_factory/app.py b/backend/druks/contrib/software_factory/app.py index 34e8e5cf..a91e3ed2 100644 --- a/backend/druks/contrib/software_factory/app.py +++ b/backend/druks/contrib/software_factory/app.py @@ -1,5 +1,6 @@ from typing import Literal +import httpx from pydantic import Field from druks.agents import Agent @@ -23,6 +24,7 @@ from druks.db import StoredSubject from druks.doctor import CheckResult from druks.services import ServiceNotConnectedError +from druks.settings import load_settings from druks.workflows import SubjectActivity # Only what the timeline can't already show. A running agent has an agent call @@ -66,6 +68,38 @@ async def check_review_identity() -> CheckResult: ) +async def check_issues_mcp() -> CheckResult: + """Whether this appliance's /mcp answers, so an issues build can fetch + and comment. Linear and Jira do not need it.""" + if (await SoftwareFactory.settings()).tracker != "issues": + return CheckResult(name="issues_mcp", ok=True, detail="not required") + endpoint = load_settings().urls.endpoint.rstrip("/") + if not endpoint: + return CheckResult( + name="issues_mcp", + ok=False, + pending=True, + detail="urls.endpoint is unset — the sandbox needs it to reach /mcp.", + ) + url = f"{endpoint}/mcp" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(url) + except httpx.RequestError as error: + return CheckResult( + name="issues_mcp", + ok=False, + detail=f"{url} is unreachable: {error}. The issues tracker tools need it.", + ) + if response.status_code >= 500: + return CheckResult( + name="issues_mcp", + ok=False, + detail=f"{url} returned {response.status_code}. The issues tracker tools need it.", + ) + return CheckResult(name="issues_mcp", ok=True, detail=url) + + class SoftwareFactory(App): name = "software_factory" # These tables (projects, work_items, ...) are already unprefixed in core's @@ -155,7 +189,7 @@ def clean(self) -> dict[str, str]: problems["review_app_id"] = "Required once the review App private key is set." return problems - checks = [check_tracker_identity, check_review_identity] + checks = [check_tracker_identity, check_review_identity, check_issues_mcp] @classmethod async def get_tracker(cls, source: str | None = None) -> Tracker | None: diff --git a/backend/druks/contrib/software_factory/constants.py b/backend/druks/contrib/software_factory/constants.py index 26353a08..93ba4355 100644 --- a/backend/druks/contrib/software_factory/constants.py +++ b/backend/druks/contrib/software_factory/constants.py @@ -4,3 +4,6 @@ # act as (druks.contrib.software_factory.github). GITHUB_MCP_NAME = "github" GITHUB_MCP_URL = "https://api.githubcopilot.com/mcp/" +# The appliance /mcp, required when the tracker is issues. Same doors the +# dashboard uses; the sandbox reaches them here, not through Linear. +APPLIANCE_MCP_NAME = "druks" diff --git a/backend/druks/contrib/software_factory/workflows.py b/backend/druks/contrib/software_factory/workflows.py index 9faedd08..3e2eda7e 100644 --- a/backend/druks/contrib/software_factory/workflows.py +++ b/backend/druks/contrib/software_factory/workflows.py @@ -1,10 +1,11 @@ import logging from dataclasses import dataclass from typing import TYPE_CHECKING, Any +from urllib.parse import urlsplit, urlunsplit from pydantic import BaseModel, Field -from druks.accounts.models import Account +from druks.accounts.models import Account, PersonalAccessToken from druks.contrib.software_factory.contracts import ImplementationOutput, ReviewWork from druks.contrib.software_factory.enums import ( EvaluationVerdict, @@ -23,7 +24,7 @@ from druks.workspaces import RepoWorkspace from .app import SoftwareFactory -from .constants import GITHUB_MCP_NAME, GITHUB_MCP_URL +from .constants import APPLIANCE_MCP_NAME, GITHUB_MCP_NAME, GITHUB_MCP_URL from .datastructures import PullRequest from .github import get_review_actor from .journal import BuildJournal @@ -35,6 +36,26 @@ logger = logging.getLogger(__name__) +_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) + + +def appliance_mcp_url() -> str: + """The appliance /mcp as a sandbox reaches this process. Loopback is this + host, not the VM, so it becomes the Docker host gateway.""" + endpoint = load_settings().urls.endpoint.rstrip("/") + if not endpoint: + raise FatalError( + "urls.endpoint is unset; the issues tracker tools need /mcp reachable from the sandbox." + ) + parts = urlsplit(endpoint) + host = parts.hostname or "" + if host in _LOOPBACK_HOSTS: + port = f":{parts.port}" if parts.port else "" + endpoint = urlunsplit( + (parts.scheme, f"host.docker.internal{port}", parts.path, "", "") + ).rstrip("/") + return f"{endpoint}/mcp" + @dataclass(frozen=True, kw_only=True) class BuildWorkspace(RepoWorkspace): @@ -42,13 +63,28 @@ class BuildWorkspace(RepoWorkspace): # Installation token for build's github MCP server, minted per repo from # the identity reviews act as. Required — there is no build without github. mcp_token: str + # Appliance /mcp, set only when the tracker is issues. Empty otherwise — + # Linear and Jira do not take this server. + appliance_mcp_url: str = "" + appliance_mcp_token: str = "" @property def workspace_root(self) -> str: return get_work_root(self.host.ssh_username) def get_required_mcp_servers(self) -> tuple[RequiredMcpServer, ...]: - return (RequiredMcpServer(name=GITHUB_MCP_NAME, url=GITHUB_MCP_URL, token=self.mcp_token),) + servers = ( + RequiredMcpServer(name=GITHUB_MCP_NAME, url=GITHUB_MCP_URL, token=self.mcp_token), + ) + if self.appliance_mcp_url: + servers += ( + RequiredMcpServer( + name=APPLIANCE_MCP_NAME, + url=self.appliance_mcp_url, + token=self.appliance_mcp_token, + ), + ) + return servers async def run_agent(self, *, account_id: str | None, **kwargs: Any): # Agents clone related repos on demand; Claude's --add-dir target must exist first. @@ -193,13 +229,33 @@ async def get_workspace_kwargs(self, host: "Host") -> dict[str, Any]: f"Could not mint the GitHub token for {repo}; build requires it " "for its github MCP server." ) from error - return { + kwargs = { **kwargs, # None until the first implement provisions the PR branch. "branch": self.branch, "mcp_token": mcp_token, "skills": tuple(self._profile.get("recommended_skills", [])), } + if (await SoftwareFactory.settings()).tracker == "issues": + kwargs["appliance_mcp_url"] = appliance_mcp_url() + account_id = self.account_id + if account_id: + account = await Account.get(account_id) + if not account: + raise FatalError( + f"issues tracker tools need account {account_id} to mint the /mcp PAT." + ) + else: + account = await Account.get_default() + if not account: + raise FatalError( + "issues tracker tools need a run account or a default " + "account to mint the /mcp PAT." + ) + _, kwargs["appliance_mcp_token"] = await PersonalAccessToken.create( + account_id=account.id, name="issues sandbox" + ) + return kwargs async def get_prompt_context(self, **context: Any) -> dict[str, Any]: work_item = await self.subject diff --git a/backend/tests/software_factory/test_build_workspace.py b/backend/tests/software_factory/test_build_workspace.py index f2bf2e8b..dc7489bf 100644 --- a/backend/tests/software_factory/test_build_workspace.py +++ b/backend/tests/software_factory/test_build_workspace.py @@ -6,7 +6,13 @@ import pytest from druks import workspaces as workspace_mod -from druks.contrib.software_factory.constants import GITHUB_MCP_NAME, GITHUB_MCP_URL +from druks.accounts.models import Account +from druks.contrib.software_factory.app import SoftwareFactory +from druks.contrib.software_factory.constants import ( + APPLIANCE_MCP_NAME, + GITHUB_MCP_NAME, + GITHUB_MCP_URL, +) from druks.contrib.software_factory.workflows import Build, BuildWorkspace from druks.mcp.helpers import get_bearer_token_env_var from druks.sandbox import host as host_mod @@ -83,6 +89,25 @@ async def test_build_workspace_declares_its_github_mcp(druks_db): github = next(s for s in kwargs["mcp_servers"] if s.name == GITHUB_MCP_NAME) assert github.url == GITHUB_MCP_URL assert "ghs_review" not in repr(github) + assert APPLIANCE_MCP_NAME not in {s.name for s in kwargs["mcp_servers"]} + + +async def test_issues_tracker_requires_appliance_mcp(): + workspace = BuildWorkspace( + host=_FakeSandbox(), # type: ignore[arg-type] + subject=SimpleNamespace(repo="o/main"), + branch="b", + mcp_token="ghs_review", + skills=("python-house-rules",), + appliance_mcp_url="http://host.docker.internal:8001/mcp", + appliance_mcp_token="druks_pat_test", + ) + kwargs = await workspace.with_mcp_servers(None, **workspace.get_agent_run_kwargs()) + + appliance = next(s for s in kwargs["mcp_servers"] if s.name == APPLIANCE_MCP_NAME) + assert appliance.url == "http://host.docker.internal:8001/mcp" + assert kwargs["extra_env"][get_bearer_token_env_var(APPLIANCE_MCP_NAME)] == "druks_pat_test" + assert "druks_pat_test" not in repr(appliance) def _review_actor_stub(monkeypatch: pytest.MonkeyPatch, *, review_actor) -> None: @@ -93,7 +118,9 @@ async def _review_actor(): @pytest.mark.asyncio -async def test_get_workspace_kwargs_carries_the_build_fields(monkeypatch: pytest.MonkeyPatch): +async def test_get_workspace_kwargs_carries_the_build_fields( + druks_db, monkeypatch: pytest.MonkeyPatch +): async def _review_token(_repo: str) -> str: return "ghs_review" @@ -146,6 +173,121 @@ async def _no_token(_repo: str) -> str: await workflow.get_workspace_kwargs(sandbox) +def _pin_tracker(monkeypatch: pytest.MonkeyPatch, tracker: str) -> None: + settings = SoftwareFactory.Settings(tracker=tracker) + + async def _settings(cls): + return settings + + monkeypatch.setattr(SoftwareFactory, "settings", classmethod(_settings)) + + +def _issues_workspace(monkeypatch: pytest.MonkeyPatch) -> tuple[Build, Any]: + async def _review_token(_repo: str) -> str: + return "ghs_review" + + _pin_tracker(monkeypatch, "issues") + monkeypatch.setattr( + "druks.contrib.software_factory.workflows.load_settings", + lambda: SimpleNamespace(urls=SimpleNamespace(endpoint="http://127.0.0.1:8001")), + ) + _review_actor_stub( + monkeypatch, + review_actor=lambda: SimpleNamespace( + client=SimpleNamespace(token_for_repo=_review_token), + mode="approve", + ), + ) + sandbox = host_mod.Host(record=SimpleNamespace(id="h1", ssh_username="exedev")) # type: ignore[arg-type] + workflow = Build() + workflow.input = Build._run_input_model() + workflow.subject = SimpleNamespace(repo="o/app") + workflow._profile = {"recommended_skills": ["python-house-rules"]} + workflow.account_id = None + return workflow, sandbox + + +async def test_get_workspace_kwargs_mints_a_pat_for_the_run_account(druks_db, monkeypatch): + account = await Account.get_or_create("op@example.com") + await Account.get_or_create("other@example.com") + workflow, sandbox = _issues_workspace(monkeypatch) + workflow.account_id = account.id + + kwargs = await workflow.get_workspace_kwargs(sandbox) + + assert kwargs["appliance_mcp_url"] == "http://host.docker.internal:8001/mcp" + assert kwargs["appliance_mcp_token"].startswith("druks_pat_") + workspace = BuildWorkspace(**kwargs) + assert APPLIANCE_MCP_NAME in {server.name for server in workspace.get_required_mcp_servers()} + assert "issues" not in {server.name for server in workspace.get_required_mcp_servers()} + + +async def test_get_workspace_kwargs_uses_the_sole_operator_when_unassigned(druks_db, monkeypatch): + await Account.get_or_create("op@example.com") + workflow, sandbox = _issues_workspace(monkeypatch) + + kwargs = await workflow.get_workspace_kwargs(sandbox) + + assert kwargs["appliance_mcp_token"].startswith("druks_pat_") + + +async def test_get_workspace_kwargs_keeps_a_public_mcp_endpoint(druks_db, monkeypatch): + await Account.get_or_create("op@example.com") + workflow, sandbox = _issues_workspace(monkeypatch) + monkeypatch.setattr( + "druks.contrib.software_factory.workflows.load_settings", + lambda: SimpleNamespace(urls=SimpleNamespace(endpoint="https://druks.example.com")), + ) + + kwargs = await workflow.get_workspace_kwargs(sandbox) + + assert kwargs["appliance_mcp_url"] == "https://druks.example.com/mcp" + + +async def test_get_workspace_kwargs_fails_without_an_operator_account(druks_db, monkeypatch): + workflow, sandbox = _issues_workspace(monkeypatch) + + with pytest.raises(FatalError, match="/mcp PAT"): + await workflow.get_workspace_kwargs(sandbox) + + +async def test_get_workspace_kwargs_fails_when_endpoint_is_unset(druks_db, monkeypatch): + await Account.get_or_create("op@example.com") + workflow, sandbox = _issues_workspace(monkeypatch) + monkeypatch.setattr( + "druks.contrib.software_factory.workflows.load_settings", + lambda: SimpleNamespace(urls=SimpleNamespace(endpoint="")), + ) + + with pytest.raises(FatalError, match="/mcp"): + await workflow.get_workspace_kwargs(sandbox) + + +async def test_linear_tracker_does_not_require_appliance_mcp(druks_db, monkeypatch): + async def _review_token(_repo: str) -> str: + return "ghs_review" + + _review_actor_stub( + monkeypatch, + review_actor=lambda: SimpleNamespace( + client=SimpleNamespace(token_for_repo=_review_token), + mode="approve", + ), + ) + sandbox = host_mod.Host(record=SimpleNamespace(id="h1", ssh_username="exedev")) # type: ignore[arg-type] + workflow = Build() + workflow.input = Build._run_input_model() + workflow.subject = SimpleNamespace(repo="o/app") + workflow._profile = {"recommended_skills": ["python-house-rules"]} + + kwargs = await workflow.get_workspace_kwargs(sandbox) + workspace = BuildWorkspace(**kwargs) + + servers = workspace.get_required_mcp_servers() + assert "appliance_mcp_url" not in kwargs + assert APPLIANCE_MCP_NAME not in {server.name for server in servers} + + class _IdentitySandbox: ssh_username = "exedev" diff --git a/backend/tests/software_factory/test_ticketing.py b/backend/tests/software_factory/test_ticketing.py index f7c38ab3..f5bd5e7a 100644 --- a/backend/tests/software_factory/test_ticketing.py +++ b/backend/tests/software_factory/test_ticketing.py @@ -1,9 +1,14 @@ import json +from types import SimpleNamespace import httpx import pytest from druks.apps.settings import field_choices, field_visibility, validate_field_choice_details -from druks.contrib.software_factory.app import SoftwareFactory, check_tracker_identity +from druks.contrib.software_factory.app import ( + SoftwareFactory, + check_issues_mcp, + check_tracker_identity, +) from druks.contrib.software_factory.ticketing.enums import TicketStatus from druks.contrib.software_factory.ticketing.issues import IssuesTracker from druks.contrib.software_factory.ticketing.jira import Jira @@ -242,6 +247,48 @@ async def test_tracker_builds_issues_without_credentials(druks_db, monkeypatch): assert await SoftwareFactory.get_tracker("linear") is None +async def test_issues_mcp_check_skips_when_tracker_is_not_issues(monkeypatch): + _pin_software_factory_settings(monkeypatch, tracker="linear") + + result = await check_issues_mcp() + + assert result.ok + assert result.detail == "not required" + + +async def test_issues_mcp_check_pends_without_an_endpoint(monkeypatch): + _pin_software_factory_settings(monkeypatch, tracker="issues") + monkeypatch.setattr( + "druks.contrib.software_factory.app.load_settings", + lambda: SimpleNamespace(urls=SimpleNamespace(endpoint="")), + ) + + result = await check_issues_mcp() + + assert not result.ok + assert result.pending + assert "/mcp" in result.detail + + +async def test_issues_mcp_check_names_an_unreachable_url(monkeypatch): + _pin_software_factory_settings(monkeypatch, tracker="issues") + monkeypatch.setattr( + "druks.contrib.software_factory.app.load_settings", + lambda: SimpleNamespace(urls=SimpleNamespace(endpoint="http://druks.test:8001")), + ) + + async def fake_get(self, url): + raise httpx.ConnectError("connection refused", request=httpx.Request("GET", url)) + + monkeypatch.setattr(httpx.AsyncClient, "get", fake_get) + + result = await check_issues_mcp() + + assert not result.ok + assert not result.pending + assert "http://druks.test:8001/mcp" in result.detail + + # --- Linear provider -------------------------------------------------------- diff --git a/backend/tests/test_mcp_endpoint.py b/backend/tests/test_mcp_endpoint.py index 0180a6a2..d3966b13 100644 --- a/backend/tests/test_mcp_endpoint.py +++ b/backend/tests/test_mcp_endpoint.py @@ -9,6 +9,7 @@ from druks.accounts.models import Account, PersonalAccessToken from druks.api.server import mcp_app from druks.contrib.software_factory.app import SoftwareFactory +from druks.contrib.software_factory.issues.models import IssuesProject, Ticket from druks.core.apis.exceptions import UnknownTicketError from druks.durable.models import Artifact, Run from druks.mcp.exceptions import InvalidAgentToolError @@ -221,6 +222,37 @@ async def test_tools_list_pins_platform_and_app_tools(app, pat_token): assert not tools["get_usage"].inputSchema.get("required") +async def test_issues_ticket_tools_read_and_comment_as_the_pat_account(app, account, pat_token): + project = await IssuesProject.create(name="widget", prefix="WID") + ticket = await Ticket.create( + project_id=project.id, title="Add an endpoint", description="do the thing" + ) + + async with live(app), _client(app, pat_token) as client: + names = {tool.name for tool in await client.list_tools()} + fetched = ( + await client.call_tool("software_factory_get_ticket", {"identifier": ticket.identifier}) + ).structured_content + commented = ( + await client.call_tool( + "software_factory_add_comment", + {"identifier": ticket.identifier, "body": "first plan"}, + ) + ).structured_content + reread = ( + await client.call_tool("software_factory_get_ticket", {"identifier": ticket.identifier}) + ).structured_content + + assert "software_factory_get_ticket" in names + assert "software_factory_add_comment" in names + assert fetched["description"] == "do the thing" + assert fetched["comments"] == [] + assert commented["author"] == account.username + assert commented["body"] == "first plan" + assert [line["body"] for line in reread["comments"]] == ["first plan"] + assert reread["comments"][0]["author"] == account.username + + @pytest.mark.parametrize( ("operation_id", "docstring", "message"), [ diff --git a/docs/configuration.md b/docs/configuration.md index d9d3bb0e..067d8cdf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -286,7 +286,9 @@ and webhook secret. The Jira identity uses a base URL, email, API token, and web validates the credentials before it stores them. Select the tracker and its workflow statuses in **Software Factory → Settings**. Select **druks** to use Software Factory's local issue board on this appliance. That choice needs no -credentials. `druks doctor` reports it as healthy. +credentials. `druks doctor` reports it as healthy. Each build then ships this +appliance's `/mcp` into the sandbox so the agent can read and comment on the +ticket. Set `urls.endpoint` so the VM can reach it. Webhook URLs remain `/_external/linear/events/` and `/_external/jira/events/`. The Jira webhook uses a Jira Automation