Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions backend/druks/contrib/software_factory/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand All @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions backend/druks/contrib/software_factory/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="")
Expand Down
2 changes: 1 addition & 1 deletion backend/druks/contrib/software_factory/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions backend/druks/contrib/software_factory/ticketing/issues.py
Original file line number Diff line number Diff line change
@@ -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
122 changes: 122 additions & 0 deletions backend/tests/software_factory/test_issues_tracker.py
Original file line number Diff line number Diff line change
@@ -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)
35 changes: 35 additions & 0 deletions backend/tests/software_factory/test_ticketing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 --------------------------------------------------------


Expand Down
4 changes: 3 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/apps/software_factory/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down