Skip to content

Commit 7ae66b4

Browse files
chaoskcursoragent
andcommitted
Treat the local issue board as a Software Factory tracker.
Selecting druks needs no Service identity: status writes go through Ticket.transition and the existing funnel opens the build. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 996ee13 commit 7ae66b4

8 files changed

Lines changed: 215 additions & 10 deletions

File tree

backend/druks/contrib/software_factory/app.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
ReviewReport,
1515
TriageOutput,
1616
)
17+
from druks.contrib.software_factory.issues.enums import Status as IssuesStatus
1718
from druks.contrib.software_factory.ticketing.base import Tracker
19+
from druks.contrib.software_factory.ticketing.issues import IssuesTracker
1820
from druks.contrib.software_factory.ticketing.jira import Jira
1921
from druks.contrib.software_factory.ticketing.linear import Linear
2022
from druks.core import services
@@ -37,6 +39,8 @@ async def check_tracker_identity() -> CheckResult:
3739
settings = await SoftwareFactory.settings()
3840
if settings.tracker == "none":
3941
return CheckResult(name="tracker", ok=True, detail="trackerless by choice")
42+
if settings.tracker == "issues":
43+
return CheckResult(name="tracker", ok=True, detail="local issues board")
4044
service = {"linear": services.Linear, "jira": services.Jira}[settings.tracker]
4145
if await service.is_connected():
4246
return CheckResult(name="tracker", ok=True, detail=f"{settings.tracker} connected")
@@ -74,10 +78,18 @@ class SoftwareFactory(App):
7478
)
7579

7680
class Settings(AppSettings):
77-
tracker: Literal["none", "linear", "jira"] = Field(
81+
tracker: Literal["none", "linear", "jira", "issues"] = Field(
7882
default="linear",
7983
title="Tracker",
8084
description="Which ticket tracker this installation uses.",
85+
json_schema_extra={
86+
"choice_details": {
87+
"issues": {
88+
"label": "druks",
89+
"help": "Druks is this appliance — no credentials.",
90+
},
91+
},
92+
},
8193
)
8294
# The tracker status names that drive build's funnel. They're operator
8395
# knobs — the names an operator's Linear/Jira workflow actually uses — so
@@ -131,6 +143,8 @@ def trigger_status(self) -> str:
131143
return self.linear_trigger_status
132144
if self.tracker == "jira":
133145
return self.jira_trigger_status
146+
if self.tracker == "issues":
147+
return IssuesStatus.READY_FOR_AGENT.label
134148
return ""
135149

136150
def clean(self) -> dict[str, str]:
@@ -145,13 +159,16 @@ def clean(self) -> dict[str, str]:
145159

146160
@classmethod
147161
async def get_tracker(cls, source: str | None = None) -> Tracker | None:
148-
"""The selected tracker, once its service identity is connected; None when
149-
the installation runs trackerless or the identity is missing. Pass a
150-
``source`` to get it only when that source is the selected one — a
151-
work item syncs only to the tracker that owns it."""
162+
"""The selected tracker. Linear and Jira need a connected service identity.
163+
The local issues board does not. None when the installation runs
164+
trackerless or the identity is missing. Pass a ``source`` to get it only
165+
when that source is the selected one — a work item syncs only to the
166+
tracker that owns it."""
152167
settings = await cls.settings()
153168
if source and source != settings.tracker:
154169
return
170+
if settings.tracker == "issues":
171+
return IssuesTracker()
155172
try:
156173
if settings.tracker == "linear":
157174
row = await services.Linear.get()

backend/druks/contrib/software_factory/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,8 @@ class WorkItem(StoredSubject):
206206
ForeignKey("projects.id"),
207207
)
208208
project: Mapped[Project] = relationship(lazy="joined")
209-
# Which remote tracker the ticket lives in: ``linear`` / ``github`` /
210-
# future ``jira``. Combined with ``ticket_key`` to uniquely identify
209+
# Which tracker the ticket lives in: ``linear`` / ``github`` /
210+
# ``jira`` / ``issues``. Combined with ``ticket_key`` to uniquely identify
211211
# a ticket.
212212
source: Mapped[str] = mapped_column(default="github")
213213
title: Mapped[str] = mapped_column(default="")

backend/druks/contrib/software_factory/schemas.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ class WorkItemSummary(SubjectSummary):
6363
# The work item's domain header — what only Software Factory knows. Status (where it is
6464
# in its lifecycle) and the timeline come from the platform's subject read-side,
6565
# which composes this with them; ``id`` is the platform subject key (str).
66-
source: Literal["linear", "github", "jira"]
66+
source: Literal["linear", "github", "jira", "issues"]
6767
repo: str
6868
# Druks Project name (e.g. "Acme"), not the repo. Required —
6969
# every WorkItem is born into a project, intake refuses tickets
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from druks.contrib.software_factory.issues.enums import Status
2+
from druks.contrib.software_factory.issues.models import Ticket
3+
from druks.contrib.software_factory.ticketing.base import Tracker
4+
from druks.contrib.software_factory.ticketing.enums import TicketStatus
5+
from druks.core.apis.exceptions import UnknownTicketError
6+
7+
_BOARD = {
8+
TicketStatus.TRIGGER: Status.READY_FOR_AGENT,
9+
TicketStatus.BACKLOG: Status.BACKLOG,
10+
TicketStatus.CANCELED: Status.CANCELLED,
11+
TicketStatus.IN_PROGRESS: Status.IN_PROGRESS,
12+
TicketStatus.IN_REVIEW: Status.IN_REVIEW,
13+
TicketStatus.DONE: Status.DONE,
14+
}
15+
16+
17+
class IssuesTracker(Tracker):
18+
"""Status writes the issues row. No credentials — the board is this appliance."""
19+
20+
known_exceptions = (UnknownTicketError,)
21+
22+
async def set_status(self, key: str, status: TicketStatus) -> None:
23+
ticket = await Ticket.get_for_identifier(key)
24+
if not ticket:
25+
raise UnknownTicketError(key, "issues")
26+
await ticket.transition(_BOARD[status])
27+
28+
async def aclose(self) -> None:
29+
return
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import druks.contrib.software_factory.subscribers # noqa: F401
2+
import pytest
3+
from druks.contrib.software_factory.app import SoftwareFactory
4+
from druks.contrib.software_factory.issues.enums import Status
5+
from druks.contrib.software_factory.issues.models import IssuesProject, Ticket
6+
from druks.contrib.software_factory.models import WorkItem
7+
from druks.contrib.software_factory.ticketing.enums import TicketStatus
8+
from druks.contrib.software_factory.ticketing.issues import IssuesTracker
9+
from druks.contrib.software_factory.workflows import Build
10+
from druks.core.apis.exceptions import UnknownTicketError
11+
from druks.services.models import ServiceIdentity
12+
13+
from software_factory.factories import make_test_work_item
14+
15+
16+
def _pin_software_factory_settings(monkeypatch, **values):
17+
settings = SoftwareFactory.Settings(**values)
18+
19+
async def _settings(cls):
20+
return settings
21+
22+
monkeypatch.setattr(SoftwareFactory, "settings", classmethod(_settings))
23+
24+
25+
async def _connect_github() -> None:
26+
await ServiceIdentity.connect(
27+
"github",
28+
identity={"app_id": "1", "slug": "druks-operator"},
29+
secrets={"private_key": "operator-pem", "webhook_secret": "hook-secret"},
30+
)
31+
32+
33+
@pytest.mark.parametrize(
34+
("asked", "board"),
35+
[
36+
(TicketStatus.TRIGGER, Status.READY_FOR_AGENT),
37+
(TicketStatus.IN_PROGRESS, Status.IN_PROGRESS),
38+
(TicketStatus.IN_REVIEW, Status.IN_REVIEW),
39+
(TicketStatus.DONE, Status.DONE),
40+
(TicketStatus.BACKLOG, Status.BACKLOG),
41+
(TicketStatus.CANCELED, Status.CANCELLED),
42+
],
43+
)
44+
async def test_issues_tracker_maps_ticket_status_onto_the_board(druks_db, asked, board):
45+
project = await IssuesProject.create(name="widget", prefix="WID")
46+
ticket = await Ticket.create(project_id=project.id, title="one")
47+
48+
async with IssuesTracker() as tracker:
49+
await tracker.set_status(ticket.identifier, asked)
50+
51+
assert (await Ticket.get_for_identifier(ticket.identifier)).status == board
52+
53+
54+
async def test_issues_tracker_raises_for_an_unknown_key(druks_db):
55+
with pytest.raises(UnknownTicketError, match="NOPE-1"):
56+
await IssuesTracker().set_status("NOPE-1", TicketStatus.IN_PROGRESS)
57+
58+
59+
@pytest.mark.parametrize(
60+
("asked", "board"),
61+
[
62+
(TicketStatus.IN_PROGRESS, Status.IN_PROGRESS),
63+
(TicketStatus.IN_REVIEW, Status.IN_REVIEW),
64+
(TicketStatus.DONE, Status.DONE),
65+
(TicketStatus.BACKLOG, Status.BACKLOG),
66+
],
67+
)
68+
async def test_work_item_status_writes_through_to_the_issues_ticket(
69+
druks_db, monkeypatch, asked, board
70+
):
71+
_pin_software_factory_settings(monkeypatch, tracker="issues")
72+
project = await IssuesProject.create(name="widget", prefix="WID")
73+
ticket = await Ticket.create(project_id=project.id, title="one")
74+
item = await make_test_work_item(
75+
repo="acme/widget", source="issues", ticket_key=ticket.identifier, title="one"
76+
)
77+
78+
await item.set_ticket_status(asked)
79+
80+
assert (await Ticket.get_for_identifier(ticket.identifier)).status == board
81+
82+
83+
async def test_ready_for_agent_opens_a_build_when_the_project_names_a_repo(druks_db, monkeypatch):
84+
await _connect_github()
85+
_pin_software_factory_settings(monkeypatch, tracker="issues")
86+
await make_test_work_item(repo="acme/widget", title="seed", ticket_key="SEED-1")
87+
project = await IssuesProject.create(name="widget", prefix="WID")
88+
ticket = await Ticket.create(project_id=project.id, title="Add an endpoint")
89+
started = []
90+
91+
async def fake_start(cls, **kwargs):
92+
started.append(kwargs)
93+
return "run-1"
94+
95+
monkeypatch.setattr(Build, "start", classmethod(fake_start))
96+
97+
await ticket.transition(Status.READY_FOR_AGENT)
98+
99+
item = await WorkItem.get_for_ticket_key(source="issues", ticket_key=ticket.identifier)
100+
assert item.source == "issues"
101+
assert item.ticket_key == "WID-1"
102+
assert started[0]["subject"].id == item.id
103+
104+
105+
async def test_ready_for_agent_skips_when_the_project_names_no_repo(druks_db, monkeypatch, caplog):
106+
_pin_software_factory_settings(monkeypatch, tracker="issues")
107+
project = await IssuesProject.create(name="no-such-repo", prefix="NSR")
108+
ticket = await Ticket.create(project_id=project.id, title="orphan")
109+
started = []
110+
111+
async def fake_start(cls, **kwargs):
112+
started.append(kwargs)
113+
return "run-x"
114+
115+
monkeypatch.setattr(Build, "start", classmethod(fake_start))
116+
117+
with caplog.at_level("INFO"):
118+
await ticket.transition(Status.READY_FOR_AGENT)
119+
120+
assert started == []
121+
assert await WorkItem.get_for_ticket_key(source="issues", ticket_key=ticket.identifier) is None
122+
assert any("no routable repo" in record.getMessage() for record in caplog.records)

backend/tests/software_factory/test_ticketing.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
import httpx
44
import pytest
5+
from druks.apps.settings import field_choices, field_visibility, validate_field_choice_details
56
from druks.contrib.software_factory.app import SoftwareFactory, check_tracker_identity
67
from druks.contrib.software_factory.ticketing.enums import TicketStatus
8+
from druks.contrib.software_factory.ticketing.issues import IssuesTracker
79
from druks.contrib.software_factory.ticketing.jira import Jira
810
from druks.contrib.software_factory.ticketing.linear import Linear
911
from druks.core import services
@@ -207,6 +209,39 @@ async def test_tracker_check_pends_a_selected_unconnected_tracker(druks_db, monk
207209
assert "jira" in result.detail
208210

209211

212+
async def test_tracker_check_accepts_issues_without_a_service(monkeypatch):
213+
_pin_software_factory_settings(monkeypatch, tracker="issues")
214+
215+
result = await check_tracker_identity()
216+
217+
assert result.ok
218+
assert result.detail == "local issues board"
219+
assert not result.pending
220+
221+
222+
def test_issues_is_a_tracker_choice_and_hides_the_name_knobs():
223+
fields = SoftwareFactory.Settings.model_fields
224+
assert field_choices(fields["tracker"]) == ["none", "linear", "jira", "issues"]
225+
assert validate_field_choice_details(fields["tracker"])["issues"] == {
226+
"label": "druks",
227+
"help": "Druks is this appliance — no credentials.",
228+
}
229+
assert SoftwareFactory.Settings(tracker="issues").trigger_status == "Ready for Agent"
230+
assert field_visibility(fields["linear_trigger_status"]) == ("tracker", "linear")
231+
assert field_visibility(fields["linear_resting_status"]) == ("tracker", "linear")
232+
assert field_visibility(fields["jira_trigger_status"]) == ("tracker", "jira")
233+
assert field_visibility(fields["jira_resting_status"]) == ("tracker", "jira")
234+
235+
236+
async def test_tracker_builds_issues_without_credentials(druks_db, monkeypatch):
237+
_pin_software_factory_settings(monkeypatch, tracker="issues")
238+
239+
tracker = await SoftwareFactory.get_tracker("issues")
240+
241+
assert isinstance(tracker, IssuesTracker)
242+
assert await SoftwareFactory.get_tracker("linear") is None
243+
244+
210245
# --- Linear provider --------------------------------------------------------
211246

212247

docs/configuration.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,9 @@ Tracker credentials are service identities. Connect Linear or Jira Cloud from
258258
**Settings → Connections → Services**. The Linear identity uses an API key
259259
and webhook secret. The Jira identity uses a base URL, email, API token, and webhook secret. Druks
260260
validates the credentials before it stores them. Select the tracker and its
261-
workflow statuses in **Software Factory → Settings**.
261+
workflow statuses in **Software Factory → Settings**. Select **druks** to use
262+
Software Factory's local issue board on this appliance. That choice needs no
263+
credentials. `druks doctor` reports it as healthy.
262264

263265
Webhook URLs remain `/_external/linear/events/` and
264266
`/_external/jira/events/`. The Jira webhook uses a Jira Automation

frontend/src/apps/software_factory/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export interface Links {
3939
}
4040

4141
export interface WorkItemSummary extends SubjectSummary {
42-
source: 'linear' | 'github' | 'jira'
42+
source: 'linear' | 'github' | 'jira' | 'issues'
4343
repo: string
4444
projectName: string
4545
title: string

0 commit comments

Comments
 (0)