diff --git a/backend/druks/contrib/software_factory/app.py b/backend/druks/contrib/software_factory/app.py index 36ee0d39..34e8e5cf 100644 --- a/backend/druks/contrib/software_factory/app.py +++ b/backend/druks/contrib/software_factory/app.py @@ -14,7 +14,9 @@ ReviewReport, TriageOutput, ) +from druks.contrib.software_factory.issues.enums import Status as IssuesStatus from druks.contrib.software_factory.ticketing.base import Tracker +from druks.contrib.software_factory.ticketing.issues import IssuesTracker from druks.contrib.software_factory.ticketing.jira import Jira from druks.contrib.software_factory.ticketing.linear import Linear from druks.core import services @@ -37,6 +39,8 @@ async def check_tracker_identity() -> CheckResult: settings = await SoftwareFactory.settings() if settings.tracker == "none": return CheckResult(name="tracker", ok=True, detail="trackerless by choice") + if settings.tracker == "issues": + return CheckResult(name="tracker", ok=True, detail="local issues board") service = {"linear": services.Linear, "jira": services.Jira}[settings.tracker] if await service.is_connected(): return CheckResult(name="tracker", ok=True, detail=f"{settings.tracker} connected") @@ -74,10 +78,18 @@ class SoftwareFactory(App): ) class Settings(AppSettings): - tracker: Literal["none", "linear", "jira"] = Field( + tracker: Literal["none", "linear", "jira", "issues"] = Field( default="linear", title="Tracker", description="Which ticket tracker this installation uses.", + json_schema_extra={ + "choice_details": { + "issues": { + "label": "druks", + "help": "Druks is this appliance — no credentials.", + }, + }, + }, ) # The tracker status names that drive build's funnel. They're operator # knobs — the names an operator's Linear/Jira workflow actually uses — so @@ -131,6 +143,8 @@ def trigger_status(self) -> str: return self.linear_trigger_status if self.tracker == "jira": return self.jira_trigger_status + if self.tracker == "issues": + return IssuesStatus.READY_FOR_AGENT.label return "" def clean(self) -> dict[str, str]: @@ -145,13 +159,16 @@ def clean(self) -> dict[str, str]: @classmethod async def get_tracker(cls, source: str | None = None) -> Tracker | None: - """The selected tracker, once its service identity is connected; None when - the installation runs trackerless or the identity is missing. Pass a - ``source`` to get it only when that source is the selected one — a - work item syncs only to the tracker that owns it.""" + """The selected tracker. Linear and Jira need a connected service identity. + The local issues board does not. None when the installation runs + trackerless or the identity is missing. Pass a ``source`` to get it only + when that source is the selected one — a work item syncs only to the + tracker that owns it.""" settings = await cls.settings() if source and source != settings.tracker: return + if settings.tracker == "issues": + return IssuesTracker() try: if settings.tracker == "linear": row = await services.Linear.get() diff --git a/backend/druks/contrib/software_factory/models.py b/backend/druks/contrib/software_factory/models.py index 2f91d0b4..bc725c1f 100644 --- a/backend/druks/contrib/software_factory/models.py +++ b/backend/druks/contrib/software_factory/models.py @@ -206,8 +206,8 @@ class WorkItem(StoredSubject): ForeignKey("projects.id"), ) project: Mapped[Project] = relationship(lazy="joined") - # Which remote tracker the ticket lives in: ``linear`` / ``github`` / - # future ``jira``. Combined with ``ticket_key`` to uniquely identify + # Which tracker the ticket lives in: ``linear`` / ``github`` / + # ``jira`` / ``issues``. Combined with ``ticket_key`` to uniquely identify # a ticket. source: Mapped[str] = mapped_column(default="github") title: Mapped[str] = mapped_column(default="") diff --git a/backend/druks/contrib/software_factory/schemas.py b/backend/druks/contrib/software_factory/schemas.py index 76ae62df..7693d025 100644 --- a/backend/druks/contrib/software_factory/schemas.py +++ b/backend/druks/contrib/software_factory/schemas.py @@ -63,7 +63,7 @@ class WorkItemSummary(SubjectSummary): # The work item's domain header — what only Software Factory knows. Status (where it is # in its lifecycle) and the timeline come from the platform's subject read-side, # which composes this with them; ``id`` is the platform subject key (str). - source: Literal["linear", "github", "jira"] + source: Literal["linear", "github", "jira", "issues"] repo: str # Druks Project name (e.g. "Acme"), not the repo. Required — # every WorkItem is born into a project, intake refuses tickets diff --git a/backend/druks/contrib/software_factory/ticketing/issues.py b/backend/druks/contrib/software_factory/ticketing/issues.py new file mode 100644 index 00000000..21d5ad68 --- /dev/null +++ b/backend/druks/contrib/software_factory/ticketing/issues.py @@ -0,0 +1,29 @@ +from druks.contrib.software_factory.issues.enums import Status +from druks.contrib.software_factory.issues.models import Ticket +from druks.contrib.software_factory.ticketing.base import Tracker +from druks.contrib.software_factory.ticketing.enums import TicketStatus +from druks.core.apis.exceptions import UnknownTicketError + +_BOARD = { + TicketStatus.TRIGGER: Status.READY_FOR_AGENT, + TicketStatus.BACKLOG: Status.BACKLOG, + TicketStatus.CANCELED: Status.CANCELLED, + TicketStatus.IN_PROGRESS: Status.IN_PROGRESS, + TicketStatus.IN_REVIEW: Status.IN_REVIEW, + TicketStatus.DONE: Status.DONE, +} + + +class IssuesTracker(Tracker): + """Status writes the issues row. No credentials — the board is this appliance.""" + + known_exceptions = (UnknownTicketError,) + + async def set_status(self, key: str, status: TicketStatus) -> None: + ticket = await Ticket.get_for_identifier(key) + if not ticket: + raise UnknownTicketError(key, "issues") + await ticket.transition(_BOARD[status]) + + async def aclose(self) -> None: + return diff --git a/backend/tests/software_factory/test_issues_tracker.py b/backend/tests/software_factory/test_issues_tracker.py new file mode 100644 index 00000000..0a2c3bb1 --- /dev/null +++ b/backend/tests/software_factory/test_issues_tracker.py @@ -0,0 +1,122 @@ +import druks.contrib.software_factory.subscribers # noqa: F401 +import pytest +from druks.contrib.software_factory.app import SoftwareFactory +from druks.contrib.software_factory.issues.enums import Status +from druks.contrib.software_factory.issues.models import IssuesProject, Ticket +from druks.contrib.software_factory.models import WorkItem +from druks.contrib.software_factory.ticketing.enums import TicketStatus +from druks.contrib.software_factory.ticketing.issues import IssuesTracker +from druks.contrib.software_factory.workflows import Build +from druks.core.apis.exceptions import UnknownTicketError +from druks.services.models import ServiceIdentity + +from software_factory.factories import make_test_work_item + + +def _pin_software_factory_settings(monkeypatch, **values): + settings = SoftwareFactory.Settings(**values) + + async def _settings(cls): + return settings + + monkeypatch.setattr(SoftwareFactory, "settings", classmethod(_settings)) + + +async def _connect_github() -> None: + await ServiceIdentity.connect( + "github", + identity={"app_id": "1", "slug": "druks-operator"}, + secrets={"private_key": "operator-pem", "webhook_secret": "hook-secret"}, + ) + + +@pytest.mark.parametrize( + ("asked", "board"), + [ + (TicketStatus.TRIGGER, Status.READY_FOR_AGENT), + (TicketStatus.IN_PROGRESS, Status.IN_PROGRESS), + (TicketStatus.IN_REVIEW, Status.IN_REVIEW), + (TicketStatus.DONE, Status.DONE), + (TicketStatus.BACKLOG, Status.BACKLOG), + (TicketStatus.CANCELED, Status.CANCELLED), + ], +) +async def test_issues_tracker_maps_ticket_status_onto_the_board(druks_db, asked, board): + project = await IssuesProject.create(name="widget", prefix="WID") + ticket = await Ticket.create(project_id=project.id, title="one") + + async with IssuesTracker() as tracker: + await tracker.set_status(ticket.identifier, asked) + + assert (await Ticket.get_for_identifier(ticket.identifier)).status == board + + +async def test_issues_tracker_raises_for_an_unknown_key(druks_db): + with pytest.raises(UnknownTicketError, match="NOPE-1"): + await IssuesTracker().set_status("NOPE-1", TicketStatus.IN_PROGRESS) + + +@pytest.mark.parametrize( + ("asked", "board"), + [ + (TicketStatus.IN_PROGRESS, Status.IN_PROGRESS), + (TicketStatus.IN_REVIEW, Status.IN_REVIEW), + (TicketStatus.DONE, Status.DONE), + (TicketStatus.BACKLOG, Status.BACKLOG), + ], +) +async def test_work_item_status_writes_through_to_the_issues_ticket( + druks_db, monkeypatch, asked, board +): + _pin_software_factory_settings(monkeypatch, tracker="issues") + project = await IssuesProject.create(name="widget", prefix="WID") + ticket = await Ticket.create(project_id=project.id, title="one") + item = await make_test_work_item( + repo="acme/widget", source="issues", ticket_key=ticket.identifier, title="one" + ) + + await item.set_ticket_status(asked) + + assert (await Ticket.get_for_identifier(ticket.identifier)).status == board + + +async def test_ready_for_agent_opens_a_build_when_the_project_names_a_repo(druks_db, monkeypatch): + await _connect_github() + _pin_software_factory_settings(monkeypatch, tracker="issues") + await make_test_work_item(repo="acme/widget", title="seed", ticket_key="SEED-1") + project = await IssuesProject.create(name="widget", prefix="WID") + ticket = await Ticket.create(project_id=project.id, title="Add an endpoint") + started = [] + + async def fake_start(cls, **kwargs): + started.append(kwargs) + return "run-1" + + monkeypatch.setattr(Build, "start", classmethod(fake_start)) + + await ticket.transition(Status.READY_FOR_AGENT) + + item = await WorkItem.get_for_ticket_key(source="issues", ticket_key=ticket.identifier) + assert item.source == "issues" + assert item.ticket_key == "WID-1" + assert started[0]["subject"].id == item.id + + +async def test_ready_for_agent_skips_when_the_project_names_no_repo(druks_db, monkeypatch, caplog): + _pin_software_factory_settings(monkeypatch, tracker="issues") + project = await IssuesProject.create(name="no-such-repo", prefix="NSR") + ticket = await Ticket.create(project_id=project.id, title="orphan") + started = [] + + async def fake_start(cls, **kwargs): + started.append(kwargs) + return "run-x" + + monkeypatch.setattr(Build, "start", classmethod(fake_start)) + + with caplog.at_level("INFO"): + await ticket.transition(Status.READY_FOR_AGENT) + + assert started == [] + assert await WorkItem.get_for_ticket_key(source="issues", ticket_key=ticket.identifier) is None + assert any("no routable repo" in record.getMessage() for record in caplog.records) diff --git a/backend/tests/software_factory/test_ticketing.py b/backend/tests/software_factory/test_ticketing.py index f430cf69..f7c38ab3 100644 --- a/backend/tests/software_factory/test_ticketing.py +++ b/backend/tests/software_factory/test_ticketing.py @@ -2,8 +2,10 @@ 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.ticketing.enums import TicketStatus +from druks.contrib.software_factory.ticketing.issues import IssuesTracker from druks.contrib.software_factory.ticketing.jira import Jira from druks.contrib.software_factory.ticketing.linear import Linear from druks.core import services @@ -207,6 +209,39 @@ async def test_tracker_check_pends_a_selected_unconnected_tracker(druks_db, monk assert "jira" in result.detail +async def test_tracker_check_accepts_issues_without_a_service(monkeypatch): + _pin_software_factory_settings(monkeypatch, tracker="issues") + + result = await check_tracker_identity() + + assert result.ok + assert result.detail == "local issues board" + assert not result.pending + + +def test_issues_is_a_tracker_choice_and_hides_the_name_knobs(): + fields = SoftwareFactory.Settings.model_fields + assert field_choices(fields["tracker"]) == ["none", "linear", "jira", "issues"] + assert validate_field_choice_details(fields["tracker"])["issues"] == { + "label": "druks", + "help": "Druks is this appliance — no credentials.", + } + assert SoftwareFactory.Settings(tracker="issues").trigger_status == "Ready for Agent" + assert field_visibility(fields["linear_trigger_status"]) == ("tracker", "linear") + assert field_visibility(fields["linear_resting_status"]) == ("tracker", "linear") + assert field_visibility(fields["jira_trigger_status"]) == ("tracker", "jira") + assert field_visibility(fields["jira_resting_status"]) == ("tracker", "jira") + + +async def test_tracker_builds_issues_without_credentials(druks_db, monkeypatch): + _pin_software_factory_settings(monkeypatch, tracker="issues") + + tracker = await SoftwareFactory.get_tracker("issues") + + assert isinstance(tracker, IssuesTracker) + assert await SoftwareFactory.get_tracker("linear") is None + + # --- Linear provider -------------------------------------------------------- diff --git a/docs/configuration.md b/docs/configuration.md index ee0df3d2..d9d3bb0e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -284,7 +284,9 @@ Tracker credentials are service identities. Connect Linear or Jira Cloud from **Settings → Connections → Services**. The Linear identity uses an API key and webhook secret. The Jira identity uses a base URL, email, API token, and webhook secret. Druks validates the credentials before it stores them. Select the tracker and its -workflow statuses in **Software Factory → Settings**. +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. Webhook URLs remain `/_external/linear/events/` and `/_external/jira/events/`. The Jira webhook uses a Jira Automation diff --git a/frontend/src/apps/software_factory/api.ts b/frontend/src/apps/software_factory/api.ts index 35fb531f..afa2d27d 100644 --- a/frontend/src/apps/software_factory/api.ts +++ b/frontend/src/apps/software_factory/api.ts @@ -39,7 +39,7 @@ export interface Links { } export interface WorkItemSummary extends SubjectSummary { - source: 'linear' | 'github' | 'jira' + source: 'linear' | 'github' | 'jira' | 'issues' repo: string projectName: string title: string