Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
37bfb09
DRU-388 - Software Factory local board: projects, tickets, comments (…
chaosk Sep 8, 2026
a63d366
DRU-389 - Issue board doors: create, move, edit, comment (#478)
chaosk Sep 8, 2026
0d29f74
DRU-390 - Issue board, list, and ticket pages, mounted from Software …
chaosk Sep 8, 2026
1d16ca3
Treat the local issue board as a Software Factory tracker. (#419)
chaosk Sep 8, 2026
5e9b0d6
DRU-393 - Ship the appliance /mcp into issues builds so agents can fe…
chaosk Sep 8, 2026
4d823a4
Make board cards the ticket destination. (#470)
chaosk Sep 8, 2026
cae589d
DRU-486 - Make the ticket page save in place (#471)
chaosk Sep 8, 2026
8d7c8e8
DRU-487 - Issues: tickets select a repo (#472)
chaosk Sep 8, 2026
a77d8ac
Edit ticket description and comments as WYSIWYG markdown. (#473)
chaosk Sep 8, 2026
d72e01b
Sync Ready for Agent onto a live build instead of restarting it. (#474)
chaosk Sep 8, 2026
7997f5f
Tell the planner to fetch the Druks ticket, not a GitHub issue. (#475)
chaosk Sep 8, 2026
ba4fe3b
Document the local issue board against current Software Factory behav…
chaosk Sep 10, 2026
cb4fa7e
Put linked and unlinked table cells on one baseline. (#480)
chaosk Sep 10, 2026
3405aa7
Name who opened a ticket on the detail sidebar. (#481)
chaosk Sep 10, 2026
c5632d9
Let board cards drag onto a column to change status. (#482)
chaosk Sep 10, 2026
a879d29
Drop Todo so Backlog is the only waiting pile. (#512)
chaosk Sep 10, 2026
9098e01
Remove the Issues list so the board is the only ticket surface. (#513)
chaosk Sep 10, 2026
07d2a49
Call the person who holds a ticket its owner, and default create to t…
chaosk Sep 10, 2026
adda375
Derive a project prefix from the name and walk clashes instead of ask…
chaosk Sep 10, 2026
ab6ff1e
Drop Cancelled and show Blocked next to In Progress.
chaosk Sep 10, 2026
90e8597
Join the issues-tracker Alembic chain onto current main.
chaosk Sep 11, 2026
8a97dee
Seed GitHub in issues tracker tests through the vault helper.
chaosk Sep 11, 2026
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
63 changes: 57 additions & 6 deletions backend/druks/contrib/software_factory/app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Literal

import httpx
from pydantic import Field

from druks.agents import Agent
Expand All @@ -14,12 +15,15 @@
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
from druks.doctor import CheckResult
from druks.services import ServiceNotConnectedError
from druks.settings import load_settings

from .services import GithubReviewer

Expand All @@ -30,6 +34,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 All @@ -55,6 +61,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
Expand All @@ -67,10 +105,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 @@ -111,19 +157,24 @@ 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 ""

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:
"""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
3 changes: 3 additions & 0 deletions backend/druks/contrib/software_factory/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
37 changes: 37 additions & 0 deletions backend/druks/contrib/software_factory/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,43 @@
from druks.api.exceptions import AgentApiError


class ProjectNotFound(Exception):
def __init__(self, project_id: int) -> None:
super().__init__(f"project {project_id} does not exist")


class RepoNotFound(Exception):
def __init__(self, repo_id: int) -> None:
super().__init__(f"repo {repo_id} does not exist")


class InvalidPrefix(Exception):
def __init__(self, prefix: str) -> None:
super().__init__(
f"project prefix {prefix!r} must be 2-6 letters A-Z, or two letters and a digit 1-9"
)


class MissingPrefix(Exception):
def __init__(self, name: str) -> None:
super().__init__(
f"project {name!r} has no ticket prefix — set one before minting identifiers"
)


class PrefixLocked(Exception):
def __init__(self, prefix: str) -> None:
super().__init__(
f"project prefix {prefix!r} has already minted tickets — the identifier "
"namespace is fixed once a number has been handed out"
)


class PrefixTaken(Exception):
def __init__(self, prefix: str) -> None:
super().__init__(f"project prefix {prefix!r} is already in use. Pick a different one.")


class TicketNotFound(AgentApiError):
status_code = 404
code = "TICKET_NOT_FOUND"
Expand Down
Empty file.
51 changes: 51 additions & 0 deletions backend/druks/contrib/software_factory/issues/enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from enum import StrEnum


class Status(StrEnum):
"""The board's workflow, closed on purpose: the enum *is* the workflow, so a
column can never hold a status no screen knows how to render."""

BACKLOG = "backlog"
READY_FOR_AGENT = "ready_for_agent"
IN_PROGRESS = "in_progress"
BLOCKED = "blocked"
IN_REVIEW = "in_review"
DONE = "done"

@property
def label(self) -> str:
"""What a column header or a chip spells this status as."""
return STATUS_LABELS[self]

@property
def completed(self) -> bool:
"""The work got done. Funnel readers count this, not the display label."""
return self is Status.DONE

@property
def terminal(self) -> bool:
"""Nothing moves out of here on its own. Terminal-ness lives on the
enum, not on the display label: a board that renames a column has not
changed its workflow, and every reader of ``ticket.transitioned`` reads
this rather than guessing from a string."""
return self is Status.DONE


# Pinned display labels — the stored value stays snake_case forever; only these
# strings change when the board wants different words.
STATUS_LABELS: dict[Status, str] = {
Status.BACKLOG: "Backlog",
Status.READY_FOR_AGENT: "Ready for Agent",
Status.IN_PROGRESS: "In Progress",
Status.BLOCKED: "Blocked",
Status.IN_REVIEW: "In Review",
Status.DONE: "Done",
}


class Priority(StrEnum):
NONE = "none"
URGENT = "urgent"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
Loading