diff --git a/backend/druks/contrib/software_factory/app.py b/backend/druks/contrib/software_factory/app.py index 459f4478..f81621b7 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 @@ -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 @@ -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") @@ -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 @@ -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 @@ -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() 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/exceptions.py b/backend/druks/contrib/software_factory/exceptions.py index 19ac451b..4d056243 100644 --- a/backend/druks/contrib/software_factory/exceptions.py +++ b/backend/druks/contrib/software_factory/exceptions.py @@ -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" diff --git a/backend/druks/contrib/software_factory/issues/__init__.py b/backend/druks/contrib/software_factory/issues/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/druks/contrib/software_factory/issues/enums.py b/backend/druks/contrib/software_factory/issues/enums.py new file mode 100644 index 00000000..10c1943c --- /dev/null +++ b/backend/druks/contrib/software_factory/issues/enums.py @@ -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" diff --git a/backend/druks/contrib/software_factory/issues/models.py b/backend/druks/contrib/software_factory/issues/models.py new file mode 100644 index 00000000..c8d16942 --- /dev/null +++ b/backend/druks/contrib/software_factory/issues/models.py @@ -0,0 +1,234 @@ +from datetime import datetime + +import sqlalchemy as sa +from sqlalchemy import ForeignKey, select +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from druks.accounts.models import Account +from druks.contrib.software_factory.exceptions import RepoNotFound +from druks.contrib.software_factory.issues.enums import Priority, Status +from druks.contrib.software_factory.issues.schemas import TicketSummary +from druks.contrib.software_factory.models import Project, ProjectRepo +from druks.db import Base, StoredSubject, db_session +from druks.signals import publish + + +class Ticket(StoredSubject): + __tablename__ = "issues_tickets" + + # id: the integer subject key inherited from StoredSubject; the class name + # derives subject_type "ticket". + identifier: Mapped[str] = mapped_column(unique=True) + title: Mapped[str] + description: Mapped[str] = mapped_column(default="") + # Status and priority are String columns driven by this app's closed + # StrEnums, not native PG enum types: the workflow stays in code and a label + # change never needs an ALTER TYPE. + status: Mapped[str] = mapped_column(default=Status.BACKLOG) + priority: Mapped[str] = mapped_column(default=Priority.NONE) + # Required: a ticket names the repo its PR will land in, and the identifier + # is minted from that repo's project. + repo_id: Mapped[int] = mapped_column(ForeignKey("project_repos.id")) + repo: Mapped[ProjectRepo] = relationship(lazy="joined") + # Optional: a ticket exists before anyone picks it up. + owner_id: Mapped[str | None] = mapped_column( + ForeignKey("accounts.id", ondelete="RESTRICT"), default=None + ) + # Who opened it. Optional on the row so a ticket minted outside the HTTP + # door still stores; the create door stamps the signed-in account. + creator_id: Mapped[str | None] = mapped_column( + ForeignKey("accounts.id", ondelete="RESTRICT"), default=None + ) + created_at: Mapped[datetime] = mapped_column(default=Base.utc_now) + updated_at: Mapped[datetime] = mapped_column(default=Base.utc_now) + + @classmethod + async def create( + cls, + *, + repo_id: int, + title: str, + description: str = "", + status: Status = Status.BACKLOG, + priority: Priority = Priority.NONE, + owner_id: str | None = None, + creator_id: str | None = None, + ) -> "Ticket": + session = db_session() + repo = await ProjectRepo.get(repo_id) + if not repo: + raise RepoNotFound(repo_id) + ticket = cls( + identifier=await Project.mint_identifier(repo.project_id), + repo_id=repo_id, + title=title, + description=description, + status=status, + priority=priority, + owner_id=owner_id, + creator_id=creator_id, + ) + session.add(ticket) + await session.flush() + # Creating already in Ready for Agent is arriving at the trigger, the + # same as a later move into it. Backlog and the rest stay quiet: drafting + # is not a funnel event. + if status == Status.READY_FOR_AGENT: + await ticket._emit_transitioned(status) + return ticket + + def get_label(self) -> str: + # The stable handle, never the mutable title: events snapshot the label + # and the log should not disagree with itself. + return self.identifier + + def get_summary(self) -> TicketSummary: + return TicketSummary.model_validate(self) + + @classmethod + async def get_for_identifier(cls, identifier: str) -> "Ticket | None": + statement = select(cls).where(cls.identifier == identifier) + return (await db_session().scalars(statement)).first() + + @classmethod + async def list_matching( + cls, + *, + status: str = "", + priority: str = "", + owner: str = "", + creator: str = "", + project_id: int | None = None, + repo_id: int | None = None, + updated_since: datetime | None = None, + ) -> list["Ticket"]: + statement = select(cls) + if status: + statement = statement.where(cls.status == status) + if priority: + statement = statement.where(cls.priority == priority) + if owner == "none": + statement = statement.where(cls.owner_id.is_(None)) + elif owner: + statement = statement.where(cls.owner_id == owner) + if creator: + statement = statement.where(cls.creator_id == creator) + if repo_id: + statement = statement.where(cls.repo_id == repo_id) + elif project_id: + statement = statement.where( + cls.repo_id.in_(select(ProjectRepo.id).where(ProjectRepo.project_id == project_id)) + ) + if updated_since: + statement = statement.where(cls.updated_at >= updated_since) + statement = statement.order_by(cls.updated_at.desc(), cls.id.desc()) + return list(await db_session().scalars(statement)) + + @classmethod + async def list_board(cls) -> list["Ticket"]: + """Every ticket. The page groups these by status.""" + return await cls.list_matching() + + @classmethod + async def list_for_status(cls, status: Status) -> list["Ticket"]: + statement = ( + select(cls).where(cls.status == status).order_by(cls.updated_at.desc(), cls.id.desc()) + ) + return list(await db_session().scalars(statement)) + + @classmethod + async def list_summaries(cls, account_id: str | None) -> list[TicketSummary]: + # One board for the appliance: what a team is working on belongs to + # everyone reading it, not to whoever happens to be signed in. + return [ticket.get_summary() for ticket in await cls.list_board()] + + async def set_status(self, status: Status) -> None: + self.status = status + self.updated_at = Base.utc_now() + await db_session().flush() + + async def transition(self, status: Status) -> None: + """Write a new status and tell the funnel. Already-there is a no-op so + a repeat cannot dispatch a second build.""" + if self.status == status: + return + await self.set_status(status) + await self._emit_transitioned(status) + + async def _emit_transitioned(self, status: Status) -> None: + repo = await ProjectRepo.get(self.repo_id) + owner = await Account.get(self.owner_id) if self.owner_id else None + await publish( + "ticket.transitioned", + payload={ + "source": "issues", + "identifier": self.identifier, + # Display label, the way Linear and Jira publish state names: + # the funnel's trigger status is spelled as a human reads it. + "status": status.label, + "title": self.title, + "url": f"/software_factory/tickets/{self.identifier}", + # Bare repo name so Build.dispatch / ProjectRepo.lookup still + # find the PR target the operator picked. + "project_name": repo.full_name.rsplit("/", 1)[-1], + "labels": [], + "assignee_email": owner.username if owner else None, + "assignee_name": owner.username if owner else None, + "completed": status.completed, + "terminal": status.terminal, + }, + ) + + async def set_priority(self, priority: Priority) -> None: + self.priority = priority + self.updated_at = Base.utc_now() + await db_session().flush() + + async def set_owner(self, owner_id: str | None) -> None: + self.owner_id = owner_id + self.updated_at = Base.utc_now() + await db_session().flush() + + async def add_comment(self, *, author_id: str, body: str) -> "Comment": + return await Comment.create(ticket_id=self.id, author_id=author_id, body=body) + + async def list_comments(self) -> list["Comment"]: + return await Comment.list_for_ticket(self.id) + + async def delete(self) -> None: + """Drop the ticket and its thread. The project's counter is untouched — + a retired number is retired, not recycled.""" + session = db_session() + await session.execute(sa.delete(Comment).where(Comment.ticket_id == self.id)) + await session.delete(self) + await session.flush() + + +class Comment(Base): + __tablename__ = "issues_comments" + + # A row, not an event and not a StoredSubject: events stay facts about what + # happened, while a comment is editable content the thread reads back in + # order. Chat's ``Message`` is the precedent. + id: Mapped[int] = mapped_column(primary_key=True) + ticket_id: Mapped[int] = mapped_column(ForeignKey("issues_tickets.id")) + # The signed-in account that wrote it — required, so every line on a thread + # has someone's name against it. + author_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="RESTRICT")) + body: Mapped[str] + created_at: Mapped[datetime] = mapped_column(default=Base.utc_now) + + @classmethod + async def create(cls, *, ticket_id: int, author_id: str, body: str) -> "Comment": + session = db_session() + comment = cls(ticket_id=ticket_id, author_id=author_id, body=body) + session.add(comment) + await session.flush() + return comment + + @classmethod + async def list_for_ticket(cls, ticket_id: int) -> list["Comment"]: + """The thread, oldest first — a conversation reads down. A ticket nobody + has commented on is an empty list, never None.""" + statement = select(cls).where(cls.ticket_id == ticket_id).order_by(cls.created_at, cls.id) + return list(await db_session().scalars(statement)) diff --git a/backend/druks/contrib/software_factory/issues/pages.py b/backend/druks/contrib/software_factory/issues/pages.py new file mode 100644 index 00000000..b50760a7 --- /dev/null +++ b/backend/druks/contrib/software_factory/issues/pages.py @@ -0,0 +1,539 @@ +from datetime import timedelta + +from druks import ui +from druks.accounts.context import current_account_id +from druks.accounts.models import Account +from druks.contrib.software_factory.issues.enums import Priority, Status +from druks.contrib.software_factory.issues.models import Comment, Ticket +from druks.contrib.software_factory.models import Project, ProjectRepo, WorkItem +from druks.db import Base + +# The board's columns, worked-on left to right. Blocked sits next to In +# Progress so stuck work stays visible beside work in flight. +BOARD_STATUSES = ( + Status.BACKLOG, + Status.READY_FOR_AGENT, + Status.IN_PROGRESS, + Status.BLOCKED, + Status.IN_REVIEW, + Status.DONE, +) + +# The words the screens spell a priority with. The stored value stays +# snake_case; only these strings change when the board wants different words. +PRIORITY_LABELS: dict[Priority, str] = { + Priority.NONE: "No priority", + Priority.URGENT: "Urgent", + Priority.HIGH: "High", + Priority.MEDIUM: "Medium", + Priority.LOW: "Low", +} + +UNOWNED = "Unowned" +# An account that has since gone, or druks' own system actor: the row still +# reads, it just carries no name. +UNATTRIBUTED = "Unattributed" +# Empty value on a page filter: any ticket. Owner uses ``none`` for +# unowned because this empty value already means "no filter". +FILTER_ANY = "Any" +UNOWNED_FILTER = "none" +UPDATED_WINDOWS = ("today", "week", "month") + + +def _repo_options(repos: list[ProjectRepo]) -> list[ui.Option]: + """Every registered repo whose project can mint an identifier. Grouped by + GitHub project so the operator picks a repo, not a second project table.""" + return [ + ui.Option(repo.full_name, value=str(repo.id), group=repo.project.name) for repo in repos + ] + + +def _owner_options(accounts: list[Account]) -> list[ui.Option]: + """Who work can be handed to, plus nobody. Unowned carries the empty + value the doors read back as "no owner".""" + return [ui.Option(UNOWNED, value="")] + [ + ui.Option(account.username, value=account.id) for account in accounts + ] + + +def _priority_options() -> list[ui.Option]: + return [ui.Option(label, value=priority.value) for priority, label in PRIORITY_LABELS.items()] + + +def _status_options() -> list[ui.Option]: + return [ui.Option(status.label, value=status.value) for status in Status] + + +def _owner_name(owner_id: str | None, account_names: dict[str, str]) -> str: + if not owner_id: + return UNOWNED + return account_names.get(owner_id, UNATTRIBUTED) + + +def _creator_name(creator_id: str | None, account_names: dict[str, str]) -> str: + if not creator_id: + return UNATTRIBUTED + return account_names.get(creator_id, UNATTRIBUTED) + + +def _create_actions(repos: list[ProjectRepo], accounts: list[Account]) -> list[ui.Action]: + """Creation is a control on the board, not a destination: a page that lists + nothing is not where a ticket gets written.""" + repo_choices = _repo_options(repos) + return [ + ui.Action( + label="New ticket", + operation="create_ticket", + tone="primary", + fields=[ + ui.TextField(name="title", label="Title", is_required=True), + ui.SelectField( + name="repo_id", + label="Repo", + options=repo_choices, + # An empty value still paints the first option in the browser. + # The door takes an int, so the field has to start on a real id. + value=repo_choices[0].value if repo_choices else "", + is_required=True, + help_text="The GitHub repo this ticket's pull request will target.", + ), + ui.TextAreaField(name="description", label="Description", markdown=True, rows=3), + ui.SelectField( + name="status", + label="Status", + options=_status_options(), + value=Status.BACKLOG.value, + ), + ui.SelectField( + name="priority", + label="Priority", + options=_priority_options(), + value=Priority.NONE.value, + ), + ui.SelectField( + name="owner_id", + label="Owner", + options=_owner_options(accounts), + value=current_account_id.get() or "", + ), + ], + ), + ] + + +def _ticket_link(ticket: Ticket, label: str) -> ui.Link: + return ui.Link(label, page="ticket", arguments={"identifier": ticket.identifier}) + + +def _live_form( + ticket: Ticket, + field: ui.Field, + *, + operation: str, + layout: str, + refresh: str, +) -> ui.Form: + return ui.Form( + fields=[field], + action=ui.Action( + label=f"Save {field.label.lower()}", + operation=operation, + arguments={"identifier": ticket.identifier}, + refresh=refresh, + ), + submit="change", + layout=layout, + ) + + +def _ticket_card(ticket: Ticket, account_names: dict[str, str]) -> ui.Card: + description = [ticket.identifier] + priority = Priority(ticket.priority) + if priority is not Priority.NONE: + description.append(PRIORITY_LABELS[priority]) + if ticket.owner_id: + description.append(_owner_name(ticket.owner_id, account_names)) + return ui.Card( + title=ticket.title, + description=" · ".join(description), + link=_ticket_link(ticket, ticket.title), + drag={"identifier": ticket.identifier}, + ) + + +def _comment_blocks(comments: list[Comment], account_names: dict[str, str]) -> list[ui.Card]: + return [ + ui.Card( + title=account_names.get(comment.author_id, UNATTRIBUTED), + description=comment.created_at.isoformat(sep=" ", timespec="minutes"), + blocks=[ui.Markdown(comment.body)], + ) + for comment in comments + ] + + +def _optional_int(raw: str) -> int | None: + return int(raw) if raw else None + + +def _choice(raw: str, allowed: set[str], name: str) -> str: + if not raw: + return "" + if raw not in allowed: + raise ValueError(f"{name} filter {raw!r} is not one of {sorted(allowed)}") + return raw + + +def _filter_select(name: str, label: str, options: list[ui.Option], value: str) -> ui.SelectField: + return ui.SelectField( + name=name, + label=label, + options=[ui.Option(FILTER_ANY, value=""), *options], + value=value, + ) + + +def _ticket_filters( + *, + status: str, + priority: str, + updated: str, + owner: str, + creator: str, + project: str, + repo: str, + projects: list[Project], + repos: list[ProjectRepo], + accounts: list[Account], +) -> list[ui.SelectField]: + repo_choices = [item for item in repos if not project or str(item.project_id) == project] + return [ + _filter_select( + "status", + "Status", + [ui.Option(item.label, value=item.value) for item in Status], + status, + ), + _filter_select( + "priority", + "Priority", + [ui.Option(label, value=item.value) for item, label in PRIORITY_LABELS.items()], + priority, + ), + ui.SelectField( + name="updated", + label="Updated", + options=[ + ui.Option(FILTER_ANY, value=""), + ui.Option("Today", value="today"), + ui.Option("Past week", value="week"), + ui.Option("Past month", value="month"), + ], + value=updated, + ), + ui.SelectField( + name="owner", + label="Owner", + options=[ + ui.Option(FILTER_ANY, value=""), + ui.Option(UNOWNED, value=UNOWNED_FILTER), + *[ui.Option(account.username, value=account.id) for account in accounts], + ], + value=owner, + ), + _filter_select( + "creator", + "Creator", + [ui.Option(account.username, value=account.id) for account in accounts], + creator, + ), + _filter_select( + "project", + "Project", + [ui.Option(item.name, value=str(item.id)) for item in projects], + project, + ), + _filter_select("repo", "Repo", _repo_options(repo_choices), repo), + ] + + +async def _ticket_collection( + *, + status: str = "", + priority: str = "", + updated: str = "", + owner: str = "", + creator: str = "", + project: str = "", + repo: str = "", +): + status = _choice(status, {item.value for item in Status}, "status") + priority = _choice(priority, {item.value for item in Priority}, "priority") + if updated and updated not in UPDATED_WINDOWS: + raise ValueError(f"updated filter {updated!r} is not today, week, or month") + updated_since = None + if updated: + now = Base.utc_now() + if updated == "today": + updated_since = now.replace(hour=0, minute=0, second=0, microsecond=0) + elif updated == "week": + updated_since = now - timedelta(days=7) + else: + updated_since = now - timedelta(days=30) + projects = await Project.list() + repos = await ProjectRepo.list_for_tickets() + accounts = await Account.list_all() + tickets = await Ticket.list_matching( + status=status, + priority=priority, + owner=owner, + creator=creator, + project_id=_optional_int(project), + repo_id=_optional_int(repo), + updated_since=updated_since, + ) + return ( + tickets, + repos, + accounts, + _ticket_filters( + status=status, + priority=priority, + updated=updated, + owner=owner, + creator=creator, + project=project, + repo=repo, + projects=projects, + repos=repos, + accounts=accounts, + ), + ) + + +@ui.page("/board") +async def board( + status: str = "", + priority: str = "", + updated: str = "", + owner: str = "", + creator: str = "", + project: str = "", + repo: str = "", +): + tickets, repos, accounts, filters = await _ticket_collection( + status=status, + priority=priority, + updated=updated, + owner=owner, + creator=creator, + project=project, + repo=repo, + ) + account_names = {account.id: account.username for account in accounts} + return ui.Page( + "Board", + # Built from the repos and accounts alone, so an empty install still + # offers create: the board is where a first ticket gets written. + controls=_create_actions(repos, accounts), + filters=filters, + blocks=[ + ui.Columns( + [ + ui.Section( + title=item.label, + blocks=[ + ui.Cards( + layout="stack", + drop=ui.Action( + label=f"Move to {item.label}", + operation="set_status", + arguments={"status": item.value}, + refresh="page", + ), + cards=[ + _ticket_card(ticket, account_names) + for ticket in tickets + if ticket.status == item + ], + empty=ui.EmptyState( + "Nothing here", + description=f"No ticket is in {item.label}.", + ), + ) + ], + ) + for item in BOARD_STATUSES + ] + ) + ], + ) + + +@ui.page("/tickets/{identifier}") +async def ticket(identifier: str): + found = await Ticket.get_for_identifier(identifier) + if not found: + return ui.Page( + identifier, + blocks=[ + ui.EmptyState( + "No such ticket", + description=f"Nothing on this board is named {identifier}.", + controls=[ui.Link("Board", page="board")], + ) + ], + ) + + repos = await ProjectRepo.list_for_tickets() + accounts = await Account.list_all() + account_names = {account.id: account.username for account in accounts} + comments = await found.list_comments() + thread = _comment_blocks(comments, account_names) or [ + ui.EmptyState("No comments yet", description="Say something about this ticket.") + ] + build = await WorkItem.get_for_ticket_key(source="issues", ticket_key=found.identifier) + + return ui.Page( + found.identifier, + # The whole page follows the ticket, so a status write from anywhere — + # Software Factory included — redraws it without a navigation. + follows=found, + controls=( + [ui.Link("Open build", url=f"/software_factory/work-items/{build.id}")] if build else [] + ), + blocks=[ + ui.Columns( + [ + ui.Stack( + [ + ui.Form( + fields=[ + ui.TextField( + name="title", + label="Title", + value=found.title, + is_required=True, + placeholder="Title", + ), + ui.TextAreaField( + name="description", + label="Description", + value=found.description, + placeholder="Add a description…", + rows=12, + markdown=True, + ), + ], + action=ui.Action( + label="Save", + operation="update_ticket", + arguments={"identifier": found.identifier}, + refresh="none", + ), + submit="change", + layout="prose", + ), + ui.Section( + title="Comments", + # Named, so the comment below replaces this section alone and + # the thread grows in place. + name="comments", + blocks=[ + *thread, + ui.Form( + title="Add a comment", + fields=[ + ui.TextAreaField( + name="body", + label="Comment", + is_required=True, + markdown=True, + rows=3, + ) + ], + action=ui.Action( + label="Comment", + operation="add_comment", + arguments={"identifier": found.identifier}, + tone="primary", + refresh="region", + ), + ), + ], + ), + ], + gap="large", + ), + ui.Stack( + [ + _live_form( + found, + ui.SelectField( + name="status", + label="Status", + options=_status_options(), + value=found.status, + is_required=True, + ), + operation="set_status", + layout="row", + refresh="page", + ), + _live_form( + found, + ui.SelectField( + name="priority", + label="Priority", + options=_priority_options(), + value=found.priority, + ), + operation="update_ticket", + layout="row", + refresh="page", + ), + _live_form( + found, + ui.SelectField( + name="owner_id", + label="Owner", + options=_owner_options(accounts), + value=found.owner_id or "", + ), + operation="update_ticket", + layout="row", + refresh="page", + ), + _live_form( + found, + ui.SelectField( + name="repo_id", + label="Repo", + options=_repo_options(repos), + value=str(found.repo_id), + ), + operation="update_ticket", + layout="row", + refresh="page", + ), + ui.Facts( + [ + ui.Fact("Identifier", value=ui.TextValue(found.identifier)), + ui.Fact( + "Created by", + value=ui.TextValue( + _creator_name(found.creator_id, account_names) + ), + ), + ui.Fact("Created", value=ui.TimeValue(found.created_at)), + ui.Fact("Updated", value=ui.TimeValue(found.updated_at)), + ] + ), + ], + gap="small", + ), + ], + layout="sidebar", + ) + ], + ) diff --git a/backend/druks/contrib/software_factory/issues/routes.py b/backend/druks/contrib/software_factory/issues/routes.py new file mode 100644 index 00000000..ee26a747 --- /dev/null +++ b/backend/druks/contrib/software_factory/issues/routes.py @@ -0,0 +1,211 @@ +from fastapi import APIRouter, Body, Depends, HTTPException +from fastapi import status as http_status +from sqlalchemy import select + +from druks.accounts.dependencies import current_account +from druks.accounts.models import Account +from druks.contrib.software_factory.exceptions import MissingPrefix, RepoNotFound +from druks.contrib.software_factory.issues.enums import Priority, Status +from druks.contrib.software_factory.issues.models import Ticket +from druks.contrib.software_factory.issues.schemas import CommentRead, TicketDetail, TicketEdit +from druks.contrib.software_factory.models import ProjectRepo +from druks.db import Base, db_session + +# The operations own the facts: pages call these doors, and so do the dashboard +# and the sandbox — the same doors, joined to druks ``/mcp`` as +# ``software_factory_*``. Reads are not doors — pages read the models directly — +# with one exception: a caller that cannot open the page still has to read the +# ticket it is answering. +# +# ``status`` is a field on two of these doors, so the HTTP codes come in under +# their own name. +router = APIRouter() + + +def required_text(value: str, field: str) -> str: + """Trimmed, or a refusal a form can show — whitespace is not content.""" + text = value.strip() + if not text: + raise HTTPException( + http_status.HTTP_422_UNPROCESSABLE_CONTENT, f"{field} must not be blank" + ) + return text + + +async def require_ticket(identifier: str) -> Ticket: + ticket = await Ticket.get_for_identifier(identifier) + if not ticket: + raise HTTPException(http_status.HTTP_404_NOT_FOUND, f"no ticket {identifier!r}") + return ticket + + +async def require_owner(owner_id: str) -> None: + """A ticket is owned by a real account or by nobody. The owner FK is + RESTRICT, so a bad id would surface as a 500 IntegrityError on write — + check it here instead, where the answer is a 404 the form can show.""" + if not await Account.get(owner_id): + raise HTTPException(http_status.HTTP_404_NOT_FOUND, f"no account {owner_id!r}") + + +async def ticket_detail(ticket: Ticket) -> TicketDetail: + """The ticket and its thread — ``Comment.list_for_ticket`` reads oldest + first, the order a conversation happened in.""" + comments = await ticket.list_comments() + # One SELECT for the whole thread's authors rather than one per line: + # Account has no batch door of its own, so the read is spelled here. + # Never a 5xx on a gone account either — an id the query answers for is + # named, and one it does not stays out of the map, so that line reads + # unattributed. + author_ids = {comment.author_id for comment in comments} + authors: dict[str, str] = {} + if author_ids: + rows = await db_session().scalars(select(Account).where(Account.id.in_(author_ids))) + authors = {account.id: account.username for account in rows} + return TicketDetail( + identifier=ticket.identifier, + title=ticket.title, + description=ticket.description, + status=Status(ticket.status), + priority=Priority(ticket.priority), + repo_id=ticket.repo_id, + owner_id=ticket.owner_id, + comments=[ + CommentRead( + id=comment.id, + author=authors.get(comment.author_id), + body=comment.body, + created_at=comment.created_at, + ) + for comment in comments + ], + ) + + +@router.post( + "/tickets", + status_code=http_status.HTTP_201_CREATED, + operation_id="create_ticket", + tags=["agent"], +) +async def create_ticket( + title: str = Body(..., embed=True, max_length=200), + repo_id: int = Body( + ..., embed=True, description="the GitHub repo this ticket's PR will target" + ), + description: str = Body("", embed=True), + status: Status = Body(Status.BACKLOG, embed=True), + priority: Priority = Body(Priority.NONE, embed=True), + owner_id: str | None = Body(None, embed=True), + account: Account = Depends(current_account), +) -> TicketDetail: + """Write a ticket down. It takes the next number in its repo's project's + sequence. Creating in Ready for Agent is a transition into the trigger, so a + build can open. Creating in Backlog publishes nothing.""" + title = required_text(title, "title") + # An owner select with nobody picked submits "", and the shell sends + # every field the form shows. Blank is nobody, not an account id to look up. + owner_id = owner_id or None + if owner_id is not None: + await require_owner(owner_id) + try: + ticket = await Ticket.create( + repo_id=repo_id, + title=title, + description=description, + status=status, + priority=priority, + owner_id=owner_id, + creator_id=account.id, + ) + except RepoNotFound as error: + raise HTTPException(http_status.HTTP_404_NOT_FOUND, str(error)) from error + except MissingPrefix as error: + raise HTTPException(http_status.HTTP_422_UNPROCESSABLE_CONTENT, str(error)) from error + return await ticket_detail(ticket) + + +@router.patch("/tickets/{identifier}", operation_id="update_ticket", tags=["agent"]) +async def update_ticket(identifier: str, edit: TicketEdit) -> TicketDetail: + """Edit what a ticket says — title, description, priority, owner, repo. + What you leave out stays as it was, and a title cannot be edited away. Status + is not here: a title edit is not a state transition, and ``set_status`` is the + one door that moves a ticket. Moving the repo does not remint the identifier.""" + ticket = await require_ticket(identifier) + if edit.owner_id is not None: + await require_owner(edit.owner_id) + if edit.repo_id is not None: + repo = await ProjectRepo.get(edit.repo_id) + if not repo: + raise HTTPException(http_status.HTTP_404_NOT_FOUND, f"no repo {edit.repo_id}") + if not repo.project.prefix: + raise HTTPException( + http_status.HTTP_422_UNPROCESSABLE_CONTENT, + str(MissingPrefix(repo.project.name)), + ) + + if edit.title is not None: + ticket.title = required_text(edit.title, "title") + if edit.description is not None: + ticket.description = edit.description + if edit.title is not None or edit.description is not None: + # Ticket carries setters for priority and owner but not for its + # content; stamp and flush the way those setters do. + ticket.updated_at = Base.utc_now() + await db_session().flush() + if edit.priority is not None: + await ticket.set_priority(edit.priority) + # A null owner_id means "unowned", so this field reads the caller's + # set of fields rather than the value: omitted keeps whoever holds it. + if "owner_id" in edit.model_fields_set: + await ticket.set_owner(edit.owner_id) + if edit.repo_id is not None and ticket.repo_id != edit.repo_id: + ticket.repo_id = edit.repo_id + ticket.updated_at = Base.utc_now() + await db_session().flush() + return await ticket_detail(ticket) + + +@router.post("/tickets/{identifier}/status", operation_id="set_status", tags=["agent"]) +async def set_status( + identifier: str, + status: Status = Body(..., embed=True), +) -> TicketDetail: + """Move a ticket. The transition publishes ``ticket.transitioned`` — + Software Factory's funnel reads that signal, so a move into the trigger + status is what opens a build.""" + ticket = await require_ticket(identifier) + await ticket.transition(status) + return await ticket_detail(ticket) + + +@router.post( + "/tickets/{identifier}/comments", + status_code=http_status.HTTP_201_CREATED, + operation_id="add_comment", + tags=["agent"], +) +async def add_comment( + identifier: str, + body: str = Body(..., embed=True), + account: Account = Depends(current_account), +) -> CommentRead: + """Say something on a ticket's thread. The author is you — the signed-in + account, or the account behind the token — never a field the caller picks. + Append-only: a thread is a record, so there is no edit and no delete.""" + body = required_text(body, "body") + ticket = await require_ticket(identifier) + comment = await ticket.add_comment(author_id=account.id, body=body) + return CommentRead( + id=comment.id, + author=account.username, + body=comment.body, + created_at=comment.created_at, + ) + + +@router.get("/tickets/{identifier}", operation_id="get_ticket", tags=["agent"]) +async def get_ticket(identifier: str) -> TicketDetail: + """Read one Druks board ticket by identifier (for example BOX-3), including + every comment, oldest first. This is not a GitHub issue — GitHub issue tools + cannot fetch it.""" + return await ticket_detail(await require_ticket(identifier)) diff --git a/backend/druks/contrib/software_factory/issues/schemas.py b/backend/druks/contrib/software_factory/issues/schemas.py new file mode 100644 index 00000000..2b6eca57 --- /dev/null +++ b/backend/druks/contrib/software_factory/issues/schemas.py @@ -0,0 +1,61 @@ +from datetime import datetime + +from pydantic import BaseModel, field_validator + +from druks.contrib.software_factory.issues.enums import Priority, Status +from druks.workflows import SubjectSummary + + +class TicketSummary(SubjectSummary): + # The ticket's domain header. ``label`` is the identifier + # (``Ticket.get_label``), and the platform's subject read-side composes + # this with the generic status and timeline. + title: str + status: Status + + +class CommentRead(BaseModel): + """One line on a thread. ``author`` is the account that wrote it, spelled + the only way this appliance names a person — the username — and None only + when that account is gone: a thread still reads without its author.""" + + id: int + author: str | None + body: str + created_at: datetime + + +class TicketDetail(BaseModel): + """Everything a caller needs to answer a ticket: its description and its + thread, oldest comment first. The board's summary (``TicketSummary``) is + the header; this is the ticket itself.""" + + identifier: str + title: str + description: str + status: Status + priority: Priority + repo_id: int + owner_id: str | None + comments: list[CommentRead] + + +class TicketEdit(BaseModel): + """A partial edit — what a caller leaves out stays as it was. Status is not + here: moving a ticket is ``set_status``'s job, the one door that publishes + ``ticket.transitioned``. A null ``owner_id`` is the one null that says + something: it clears the owner.""" + + title: str | None = None + description: str | None = None + priority: Priority | None = None + owner_id: str | None = None + repo_id: int | None = None + + @field_validator("owner_id", mode="before") + @classmethod + def _blank_is_nobody(cls, value: str | None) -> str | None: + # An owner select with nobody picked submits "", and the shell sends + # every field the form shows. Blank means unowned, not an account id to + # look up — the field still counts as given, so it still clears. + return value or None diff --git a/backend/druks/contrib/software_factory/models.py b/backend/druks/contrib/software_factory/models.py index ec8cc9cf..25879b25 100644 --- a/backend/druks/contrib/software_factory/models.py +++ b/backend/druks/contrib/software_factory/models.py @@ -1,11 +1,21 @@ import logging +import re from datetime import datetime from typing import Any -from sqlalchemy import ForeignKey, Index, func, select +import sqlalchemy as sa +from sqlalchemy import CheckConstraint, ForeignKey, Index, String, func, select from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.orm import Mapped, mapped_column, relationship - +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Mapped, mapped_column, relationship, validates + +from druks.contrib.software_factory.exceptions import ( + InvalidPrefix, + MissingPrefix, + PrefixLocked, + PrefixTaken, + ProjectNotFound, +) from druks.contrib.software_factory.policy import RepoPolicy from druks.contrib.software_factory.schemas import ProjectRepoSummary, WorkItemSummary from druks.contrib.software_factory.ticketing.enums import TicketStatus @@ -15,6 +25,58 @@ logger = logging.getLogger(__name__) +# A project's prefix is the identifier namespace — Linear's team key. Short +# enough to read at a glance, long enough to stay distinct. Null until set: +# a GitHub project can exist before anyone mints a ticket against it. +# Two to six letters, or the clash walk's two letters plus a digit 1-9. +PREFIX_PATTERN = r"^([A-Z]{2,6}|[A-Z]{2}[1-9])$" +PREFIX_RE = re.compile(PREFIX_PATTERN) +PREFIX_UNIQUE = "projects_prefix_key" + + +def _raise_prefix_taken(error: IntegrityError, prefix: str | None) -> None: + constraint = getattr(getattr(error.orig, "diag", None), "constraint_name", None) + if prefix and constraint == PREFIX_UNIQUE: + raise PrefixTaken(prefix) from error + + +def normalize_prefix(prefix: str) -> str: + """The stored form of an operator's prefix: uppercase, 2-6 letters, or two + letters and a digit 1-9.""" + normalized = prefix.strip().upper() + if not PREFIX_RE.match(normalized): + raise InvalidPrefix(prefix) + return normalized + + +def prefix_candidates(name: str) -> list[str]: + """Prefixes derived from a project name, first suggestion then clash walk. + + Letters only, uppercase. First is positions 1, 2, 3. A clash retries 1, 2 + and the next remaining letter (1+2+4, 1+2+5, …). After those, letters 1+2 + plus a digit 1-9. A name with fewer than two letters has no candidates. + """ + letters = "".join(ch for ch in name.upper() if ch.isascii() and ch.isalpha()) + if len(letters) < 2: + return [] + stem = letters[:2] + out: list[str] = [] + seen: set[str] = set() + + def add(candidate: str) -> None: + if candidate not in seen: + seen.add(candidate) + out.append(candidate) + + if len(letters) >= 3: + add(letters[:3]) + for extra in letters[3:]: + add(stem + extra) + for digit in "123456789": + add(f"{stem}{digit}") + return out + + # WorkItem.update() sentinel: a field left at _KEEP is untouched, while passing # None clears the (nullable) column — the two an intent flag has to tell apart. _KEEP: Any = object() @@ -22,9 +84,21 @@ class Project(Base): __tablename__ = "projects" + __table_args__ = ( + CheckConstraint( + f"(prefix IS NULL) OR (prefix ~ '{PREFIX_PATTERN}')", + name="projects_prefix_shape", + ), + ) id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(unique=True) + prefix: Mapped[str | None] = mapped_column(String(6), unique=True, default=None) + # The monotonic ticket sequence. It only ever goes up: it is bumped inside + # the UPDATE that mints an identifier and is never decremented, so deleting + # DRU-1 does not hand DRU-1 out again. Deriving the number from a count or a + # MAX over the tickets table would do exactly that, and would race besides. + ticket_seq: Mapped[int] = mapped_column(default=0) created_at: Mapped[datetime] = mapped_column(default=Base.utc_now) updated_at: Mapped[datetime] = mapped_column(default=Base.utc_now) @@ -34,14 +108,37 @@ class Project(Base): lazy="selectin", ) + @validates("prefix") + def _normalize_prefix(self, key: str, prefix: str | None) -> str | None: + # Every assignment path — create, an edit, a fixture — normalizes and + # validates, so an unshaped prefix can't reach the column. Blank is + # unset: a project can exist before it mints tickets. + if prefix is None or not str(prefix).strip(): + return None + return normalize_prefix(prefix) + @classmethod - async def create(cls, *, name: str) -> "Project": + async def create(cls, *, name: str, prefix: str | None = None) -> "Project": session = db_session() + submitted = None if prefix is None or not str(prefix).strip() else normalize_prefix(prefix) + candidates = prefix_candidates(name) + suggested = candidates[0] if candidates else None + if submitted is None or submitted == suggested: + taken = set(await session.scalars(select(cls.prefix).where(cls.prefix.is_not(None)))) + chosen = next((item for item in candidates if item not in taken), None) + if chosen is None and (submitted is not None or candidates): + raise PrefixTaken(candidates[-1] if candidates else submitted or "") + else: + chosen = submitted # Seed the collection as loaded-empty: a fresh project has no repos, and # the summary read right after flush must not trigger a lazy load. - project = cls(name=name, repos=[]) + project = cls(name=name, prefix=chosen, repos=[]) session.add(project) - await session.flush() + try: + await session.flush() + except IntegrityError as error: + _raise_prefix_taken(error, project.prefix) + raise return project @classmethod @@ -63,6 +160,56 @@ async def get_for_repo(cls, full_name: str) -> "Project | None": ) return (await db_session().scalars(stmt)).first() + @classmethod + async def list(cls) -> list["Project"]: + return list(await db_session().scalars(select(cls).order_by(cls.name))) + + @classmethod + async def mint_identifier(cls, project_id: int) -> str: + """Take the next number in this project's sequence and spell it as an + identifier. One statement: the row is locked, bumped, and read in the + same UPDATE ... RETURNING, so concurrent creates queue instead of + colliding and no number is ever handed out twice. A project with no + prefix is refused rather than minting a nameless ticket.""" + statement = ( + sa.update(cls) + .where(cls.id == project_id, cls.prefix.is_not(None)) + .values(ticket_seq=cls.ticket_seq + 1) + .returning(cls.prefix, cls.ticket_seq) + .execution_options(synchronize_session=False) + ) + row = (await db_session().execute(statement)).one_or_none() + if row: + prefix, number = row + return f"{prefix}-{number}" + project = await cls.get(project_id) + if not project: + raise ProjectNotFound(project_id) + raise MissingPrefix(project.name) + + async def set_prefix(self, prefix: str | None) -> None: + """Rename the namespace — refused once a ticket has been minted against + it, because the identifiers already handed out spell the old prefix and + are never rewritten. The counter is read from the row rather than the + instance: ``mint_identifier`` bumps it with an UPDATE this session's + copy has not seen.""" + session = db_session() + minted = await session.scalar(select(Project.ticket_seq).where(Project.id == self.id)) + # Normalize explicitly for this comparison: @validates only normalizes + # on assignment, which happens below, after the lock check. + next_prefix = ( + None if prefix is None or not str(prefix).strip() else normalize_prefix(prefix) + ) + if minted and next_prefix != self.prefix: + raise PrefixLocked(self.prefix or "") + self.prefix = prefix + self.updated_at = Base.utc_now() + try: + await session.flush() + except IntegrityError as error: + _raise_prefix_taken(error, next_prefix) + raise + class ProjectRepo(StoredSubject): __tablename__ = "project_repos" @@ -98,6 +245,18 @@ async def create( async def get(cls, repo_id: int) -> "ProjectRepo | None": return await db_session().get(cls, repo_id) + @classmethod + async def list_for_tickets(cls) -> list["ProjectRepo"]: + """Repos whose project can mint an identifier. A project without a + prefix is not a ticket target yet.""" + statement = ( + select(cls) + .join(Project) + .where(Project.prefix.is_not(None)) + .order_by(Project.name, cls.full_name) + ) + return list(await db_session().scalars(statement)) + @classmethod async def get_in_project(cls, *, project_id: int, repo_id: int) -> "ProjectRepo | None": # Scoped lookup for the nested /projects/{project_id}/repos/{repo_id} routes: @@ -205,8 +364,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="") @@ -413,3 +572,7 @@ async def update( self.project_id = project_id self.updated_at = Base.utc_now() await db_session().flush() + + +# Tickets import Project; register their tables after Project exists. +from druks.contrib.software_factory.issues import models as _issues_models # noqa: E402, F401 diff --git a/backend/druks/contrib/software_factory/prompt_context.py b/backend/druks/contrib/software_factory/prompt_context.py index 5699e417..48446248 100644 --- a/backend/druks/contrib/software_factory/prompt_context.py +++ b/backend/druks/contrib/software_factory/prompt_context.py @@ -4,6 +4,15 @@ from druks.contrib.software_factory.models import ProjectRepo from druks.skills.models import Skill +# How the prompt names the ticket's home. ``issues`` must not render as +# "Issues" — that reads as GitHub Issues and the agent fetches the wrong tool. +TRACKER_LABELS = { + "linear": "Linear", + "jira": "Jira", + "github": "GitHub", + "issues": "the Druks board", +} + @dataclass(frozen=True) class BuildPromptContext: @@ -21,6 +30,7 @@ class BuildPromptContext: pr_number: int | None ticket_ref: str | None source: str | None + tracker_label: str issue_number: int | None task_owner_name: str | None task_owner_email: str | None diff --git a/backend/druks/contrib/software_factory/routes.py b/backend/druks/contrib/software_factory/routes.py index d59ee90c..a47fb416 100644 --- a/backend/druks/contrib/software_factory/routes.py +++ b/backend/druks/contrib/software_factory/routes.py @@ -7,7 +7,14 @@ from druks.accounts.models import Account from druks.api.exceptions import agent_error_responses from druks.contrib.software_factory.app import SoftwareFactory -from druks.contrib.software_factory.exceptions import TicketNotFound, TrackerNotConfigured +from druks.contrib.software_factory.exceptions import ( + InvalidPrefix, + PrefixLocked, + PrefixTaken, + TicketNotFound, + TrackerNotConfigured, +) +from druks.contrib.software_factory.issues.models import Comment, Ticket from druks.contrib.software_factory.models import Project, ProjectRepo, WorkItem from druks.contrib.software_factory.schemas import ( AddProjectRepoRequest, @@ -51,7 +58,12 @@ async def create_project(body: CreateProjectRequest) -> ProjectSummary: name = body.name.strip() if not name: raise HTTPException(status.HTTP_400_BAD_REQUEST, "name is required") - project = await Project.create(name=name) + try: + project = await Project.create(name=name, prefix=body.prefix) + except InvalidPrefix as error: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(error)) from error + except PrefixTaken as error: + raise HTTPException(status.HTTP_409_CONFLICT, str(error)) from error return ProjectSummary.model_validate(project) @@ -116,6 +128,7 @@ async def get_project(project_id: int) -> ProjectSummary: async def update_project( project_id: int, name: str | None = Body(default=None, embed=True), + prefix: str | None = Body(default=None, embed=True), ) -> ProjectSummary: project = await Project.get(project_id) if not project: @@ -126,6 +139,13 @@ async def update_project( raise HTTPException(status.HTTP_400_BAD_REQUEST, "name cannot be empty") project.name = name await db_session().flush() + if prefix is not None: + try: + await project.set_prefix(prefix) + except (InvalidPrefix, PrefixLocked) as error: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(error)) from error + except PrefixTaken as error: + raise HTTPException(status.HTTP_409_CONFLICT, str(error)) from error return ProjectSummary.model_validate(project) @@ -139,6 +159,11 @@ async def delete_project(project_id: int) -> None: project = await Project.get(project_id) if not project: raise HTTPException(status.HTTP_404_NOT_FOUND, "project not found") + repo_ids = [repo.id for repo in project.repos] + if repo_ids: + ticket_ids = select(Ticket.id).where(Ticket.repo_id.in_(repo_ids)) + await session.execute(delete(Comment).where(Comment.ticket_id.in_(ticket_ids))) + await session.execute(delete(Ticket).where(Ticket.repo_id.in_(repo_ids))) await session.execute(delete(WorkItem).where(WorkItem.project_id == project_id)) await session.delete(project) await session.flush() diff --git a/backend/druks/contrib/software_factory/schemas.py b/backend/druks/contrib/software_factory/schemas.py index 76ae62df..f1a6f333 100644 --- a/backend/druks/contrib/software_factory/schemas.py +++ b/backend/druks/contrib/software_factory/schemas.py @@ -26,6 +26,7 @@ class ProjectSummary(Schema): id: int name: str + prefix: str | None = None created_at: datetime updated_at: datetime repos: list[ProjectRepoSummary] = Field(default_factory=list) @@ -37,6 +38,7 @@ class ProjectsResponse(Schema): class CreateProjectRequest(BaseModel): name: str + prefix: str | None = None class AddProjectRepoRequest(BaseModel): @@ -63,7 +65,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/templates/build/_contract.md b/backend/druks/contrib/software_factory/templates/build/_contract.md index 144e47bb..1b9d56c3 100644 --- a/backend/druks/contrib/software_factory/templates/build/_contract.md +++ b/backend/druks/contrib/software_factory/templates/build/_contract.md @@ -1,5 +1,5 @@ {% if build.ticket_ref %} -**MANDATORY FIRST ACTION — fetch the ticket. This is not a suggestion.** Your very first tool call MUST be to fetch `{{ build.ticket_ref }}` from {{ build.source | default('the tracker', true) | capitalize }} using your available tools, then read its full description and **every** comment before you read the codebase, write a plan, edit a file, or emit any output. Do not begin from the ticket reference, title, or the rendered plan alone — those are derived; the ticket and its operator comments are the binding source of truth, and frequently carry exact decisions you must honor verbatim. The ONLY acceptable reason to proceed without the ticket's full text is a genuine tool failure, which you must report as a blocker — never guess or fabricate the requirements. If the source materially contradicts a plan or acceptance criteria rendered below, surface the conflict rather than silently proceeding. +**MANDATORY FIRST ACTION — fetch the ticket. This is not a suggestion.** {% if build.source == "issues" %}Your very first tool call MUST be `software_factory_get_ticket` on the `druks` MCP with identifier `{{ build.ticket_ref }}`. That response is the ticket: description and every comment, oldest first. Do not use GitHub issue tools for this fetch — `{{ build.ticket_ref }}` is not a GitHub issue number.{% else %}Your very first tool call MUST be to fetch `{{ build.ticket_ref }}` from {{ build.tracker_label }} using your tracker tools, then read its full description and **every** comment.{% endif %} Do this before you read the codebase, write a plan, edit a file, or emit any output. Do not begin from the ticket reference, title, or the rendered plan alone — those are derived; the ticket and its operator comments are the binding source of truth, and frequently carry exact decisions you must honor verbatim. The ONLY acceptable reason to proceed without the ticket's full text is a genuine tool failure, which you must report as a blocker — never guess or fabricate the requirements. If the source materially contradicts a plan or acceptance criteria rendered below, surface the conflict rather than silently proceeding. {% endif %} {% if build.journal.plan.plan_markdown %} diff --git a/backend/druks/contrib/software_factory/templates/build/_header.md b/backend/druks/contrib/software_factory/templates/build/_header.md index d13b85a2..001735d6 100644 --- a/backend/druks/contrib/software_factory/templates/build/_header.md +++ b/backend/druks/contrib/software_factory/templates/build/_header.md @@ -11,7 +11,7 @@ - **Repo:** {{ build.repo }} · branch `{{ build.branch or '(none)' }}` · PR #{{ build.pr_number or '?' }}{% if build.issue_number %} · issue #{{ build.issue_number }}{% endif %} {% endif %} {% if build.ticket_ref %} -- **Ticket:** {{ build.ticket_ref }} on {{ build.source | default('the tracker', true) | capitalize }} +- **Ticket:** {{ build.ticket_ref }} on {{ build.tracker_label }} {% endif %} - **Plan revision:** {{ build.journal.plan_revision }} - **Implementation revision:** {{ build.journal.implementation_revision }}{% if build.journal.implementation_revision == 0 %} (first attempt){% endif %} diff --git a/backend/druks/contrib/software_factory/templates/build/generate_plan.md b/backend/druks/contrib/software_factory/templates/build/generate_plan.md index 397f8732..cd625664 100644 --- a/backend/druks/contrib/software_factory/templates/build/generate_plan.md +++ b/backend/druks/contrib/software_factory/templates/build/generate_plan.md @@ -108,7 +108,9 @@ understood and what is blocked, and leave `acceptance_criteria` empty. {% endif %} {% if build.journal.plan_revision == 0 %} -On this ticket's first plan only, post ONE comment on the source ticket with your tracker tools: +{% if build.source == "issues" %}On this ticket's first plan only, post ONE comment with `software_factory_add_comment` on the `druks` MCP (identifier `{{ build.ticket_ref }}`): +{% else %}On this ticket's first plan only, post ONE comment on the source ticket with your tracker tools: +{% endif %} two or three sentences stating what druks understood the work to be. {% if build.work_item_url %}Add this link on its own line: {{ build.work_item_url }} {% endif %}Never edit the ticket description and never post the plan itself. diff --git a/backend/druks/contrib/software_factory/templates/build/implement.md b/backend/druks/contrib/software_factory/templates/build/implement.md index 7b7ac041..88b6a5f5 100644 --- a/backend/druks/contrib/software_factory/templates/build/implement.md +++ b/backend/druks/contrib/software_factory/templates/build/implement.md @@ -54,7 +54,7 @@ When the implementation is complete you MUST commit and push it to the PR branch {% else %} No PR exists yet — your delivery provisions it. The repo is checked out on the default branch, so create the work branch first and implement on it (`git checkout -b `), named: -- Linear/Jira ticket: `agent/` (e.g. `agent/ACME-270`). +- Linear, Jira, or Druks board ticket: `agent/` (e.g. `agent/ACME-270`). - GitHub issue: `agent/issue--` — slug is the issue title lowercased, non-alphanumeric runs replaced with `-`, trimmed to 40 characters. When the implementation is complete, run from the repo root (pushing with `git push -u origin `; if the remote rejects the name as taken, rename with a `-2`/`-3`/… suffix and push again — never adopt an existing branch or PR): @@ -83,7 +83,7 @@ Then, on every implementation revision, regenerate the PR body above from the cu {% else %} After a successful push, open the draft PR against the default branch with the body above (`gh pr create --draft`; `gh` is authenticated): -- Title: ` - ` for a Linear/Jira ticket (just the ref when the title is empty); the GitHub issue title verbatim for an issue. +- Title: ` - ` for a Linear, Jira, or Druks board ticket (just the ref when the title is empty); the GitHub issue title verbatim when the source is GitHub. {% endif %} Authentication is already configured (a git credential helper supplies the token), so the push needs no further setup. After a successful push, report the resulting commit SHA in `head_sha` and `commit_sha`, and set `base_sha` to the commit you started from (the `git rev-parse HEAD` before your first commit). Report the branch you delivered on in `branch` and its PR number in `pr_number`. If the push is rejected because the remote branch moved, fetch and retry once (`git fetch origin && git rebase origin/`, resolve trivially, push again); if it still fails, return `status="needs_clarification"` explaining the conflict. `workspace_path` should be the repo root you worked in. 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..5ffab69a --- /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.DONE, + 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/druks/contrib/software_factory/workflows.py b/backend/druks/contrib/software_factory/workflows.py index 1343020d..91cf6f16 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, @@ -12,9 +13,12 @@ ReviewDecision, ) from druks.contrib.software_factory.models import ProjectRepo, WorkItem +from druks.contrib.software_factory.ticketing.enums import TicketStatus from druks.core.apis.github import get_github_client from druks.core.services import Github -from druks.sandbox.datastructures import RequiredMcpServer +from druks.durable.enums import RunState +from druks.mcp.helpers import get_bearer_token_env_var +from druks.sandbox.datastructures import McpServer, RequiredMcpServer from druks.sandbox.layout import get_related_root, get_work_root from druks.sandbox.models import SecretRef from druks.services.exceptions import ServiceNotConnectedError @@ -24,22 +28,47 @@ 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 from .policy import PlanGate, RepoPolicy -from .prompt_context import BuildPromptContext +from .prompt_context import TRACKER_LABELS, BuildPromptContext if TYPE_CHECKING: from druks.sandbox.host import Host 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): skills: tuple[str, ...] + # Appliance /mcp, set only when the tracker is issues. Empty otherwise — + # Linear and Jira do not take this server. The PAT is minted after the box + # exists, so it rides extra_env instead of a vault secret_id. + appliance_mcp_url: str = "" + appliance_mcp_token: str = "" @property def workspace_root(self) -> str: @@ -58,6 +87,29 @@ async def get_required_mcp_servers(cls, subject: Any) -> tuple[RequiredMcpServer ), ) + async def with_mcp_servers(self, account_id: str | None, **kwargs: Any) -> dict[str, Any]: + kwargs = await super().with_mcp_servers(account_id, **kwargs) + if not self.appliance_mcp_url: + return kwargs + variable = get_bearer_token_env_var(APPLIANCE_MCP_NAME) + servers = [ + server + for server in kwargs.get("mcp_servers") or () + if server.name != APPLIANCE_MCP_NAME + ] + servers.append( + McpServer( + name=APPLIANCE_MCP_NAME, + url=self.appliance_mcp_url, + bearer_token_env_var=variable, + ) + ) + kwargs["mcp_servers"] = tuple(servers) + env = dict(kwargs.get("extra_env") or {}) + env[variable] = self.appliance_mcp_token + kwargs["extra_env"] = env + return kwargs + 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. related_root = get_related_root(self.host.ssh_username) @@ -127,7 +179,8 @@ class Settings(BaseModel): @classmethod async def dispatch(cls, *, ticket: dict) -> str | None: # The tracker funnel's entry: a ticket at the trigger status opens a build. - # Resolve-or-refresh the item, then start (start() dedups a live run). + # A live run already holds the queue slot — start() would return it + # without announcing, so mirror the ticket onto that run instead. item = await WorkItem.get_for_ticket_key( source=ticket["source"], ticket_key=ticket["identifier"] ) @@ -138,6 +191,17 @@ async def dispatch(cls, *, ticket: dict) -> str | None: ) return await item.update(title=ticket["title"], ticket_url=ticket["url"]) + status = await item.get_status(workflow=cls) + if status.is_parked: + await item.set_ticket_status( + TicketStatus.IN_REVIEW + if status.gate == ReviewWork.name + else TicketStatus.IN_PROGRESS + ) + return + if status.state in (RunState.SCHEDULED, RunState.RUNNING): + await item.set_ticket_status(TicketStatus.IN_PROGRESS) + return else: repo = await ProjectRepo.lookup( project_name=ticket["project_name"], labels=ticket["labels"] @@ -190,12 +254,32 @@ async def run_multistep( async def get_workspace_kwargs(self, host: "Host") -> dict[str, Any]: kwargs = await super().get_workspace_kwargs(host) - return { + kwargs = { **kwargs, # None until the first implement provisions the PR branch. "branch": self.branch, "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 @@ -209,6 +293,7 @@ async def get_prompt_context(self, **context: Any) -> dict[str, Any]: pr_number=self.pr_number, ticket_ref=work_item.ticket_key, source=work_item.source, + tracker_label=TRACKER_LABELS[work_item.source], issue_number=self.input.issue_number, task_owner_name=self.input.task_owner_name, task_owner_email=self.input.task_owner_email, diff --git a/backend/druks/ui/blocks.py b/backend/druks/ui/blocks.py index 6a12f821..63ddf5b9 100644 --- a/backend/druks/ui/blocks.py +++ b/backend/druks/ui/blocks.py @@ -187,13 +187,16 @@ def check_operation(self, app_name: str, operations) -> None: class Form(PageBlock): """Inputs and the action that submits them. The shell sends the action's - arguments and the field values as one object.""" + arguments and the field values as one object. ``submit="change"`` sends + on blur for text and on change for a select, with no button.""" block: Literal["form"] = "form" title: str = "" description: str = "" fields: list[FormField] = Field(default_factory=list) action: Action + submit: Literal["button", "change"] = "button" + layout: Literal["stack", "prose", "row"] = "stack" def iter_actions(self) -> "Iterable[Action]": yield self.action @@ -207,6 +210,11 @@ def _one_name_for_each_value(self) -> "Form": raise ValueError( f"form {self.title!r} has fields on its action. Put all form fields on the form." ) + if self.submit == "change" and self.action.confirm: + raise ValueError( + f"form {self.title!r} submits on change and also asks to confirm. " + "A confirm is a press; give the form a button, or drop confirm." + ) _check_field_names( owner=f"form {self.title!r}", fields=self.fields, @@ -524,6 +532,9 @@ def __init__(self, facts=(), **data): class TableColumn(Schema): label: str align: Literal["start", "end"] = "start" + # Empty: the shell shares leftover width. Set: that column keeps this size + # in every table that names it, so stacked groups line up. + width: str = "" def __init__(self, label, **data): super().__init__(label=label, **data) @@ -539,7 +550,8 @@ def __init__(self, cells=(), **data): class Table(PageBlock): """Rows of values under named columns. Every row carries one cell for each - column; with no rows the shell shows ``empty_text``.""" + column; with no rows the shell still draws the columns and shows + ``empty_text`` in the body.""" block: Literal["table"] = "table" title: str = "" @@ -578,20 +590,27 @@ def __init__(self, blocks=(), **data): class Columns(BlockParent): - """Blocks across the page. Each child is one column; they share the width - and stack on a narrow screen.""" + """Blocks across the page. ``even`` shares the width. ``sidebar`` keeps + the last column a rail. They stack on a narrow screen.""" block: Literal["columns"] = "columns" + layout: Literal["even", "sidebar"] = "even" def __init__(self, blocks=(), **data): super().__init__(blocks=blocks, **data) class Card(BlockParent): + """A titled panel. ``link`` is its destination. The shell makes the whole + panel the control when ``controls`` is empty. ``drag`` is what a drop + action receives; empty means the card does not move.""" + block: Literal["card"] = "card" title: str = "" description: str = "" controls: list[Action | Link] = Field(default_factory=list) + link: Link | None = None + drag: dict[str, Any] = Field(default_factory=dict) def iter_actions(self) -> "Iterable[Action]": yield from super().iter_actions() @@ -602,24 +621,39 @@ def check_placement(self, *, followed: bool, regions: set[str], region: str = "" super().check_placement(followed=followed, regions=regions, region=region) for control in self.controls: control.check_placement(followed=followed, regions=regions, region=region) + if self.link: + self.link.check_placement(followed=followed, regions=regions, region=region) class Cards(PageBlock): - """One card for each of a set of things. The shell arranges them, so a page - that wants a particular geometry reaches for ``Columns`` instead.""" + """One card for each of a set of things. ``wrap`` lets the shell fit as + many across as the screen takes. ``stack`` is one column. ``drop`` is the + action a dragged card submits onto this list.""" block: Literal["cards"] = "cards" title: str = "" cards: list[Card] = Field(default_factory=list) empty: EmptyState | None = None + layout: Literal["wrap", "stack"] = "wrap" + drop: Action | None = None + + @model_validator(mode="after") + def _drop_is_immediate(self) -> "Cards": + if self.drop and (self.drop.fields or self.drop.confirm): + raise ValueError("Cards.drop cannot collect fields or confirm — the drop is the submit") + return self def iter_actions(self) -> "Iterable[Action]": + if self.drop: + yield from self.drop.iter_actions() for card in self.cards: yield from card.iter_actions() if self.empty: yield from self.empty.iter_actions() def check_placement(self, *, followed: bool, regions: set[str], region: str = "") -> None: + if self.drop: + self.drop.check_placement(followed=followed, regions=regions, region=region) for card in self.cards: card.check_placement(followed=followed, regions=regions, region=region) if self.empty: diff --git a/backend/druks/ui/exceptions.py b/backend/druks/ui/exceptions.py index 335d93ee..ef4d8126 100644 --- a/backend/druks/ui/exceptions.py +++ b/backend/druks/ui/exceptions.py @@ -1,6 +1,6 @@ class PageRouteError(Exception): """An app's pages cannot make a route table. Raised at declaration for a - nested child, and at boot for a missing landing page, a repeated page name, + nested child, and at boot for two landing pages, a repeated page name, two routes a request cannot tell apart, a signature that does not match its route, or a navigation entry that is not a static top-level page.""" diff --git a/backend/druks/ui/fields.py b/backend/druks/ui/fields.py index 9ce8fd09..4a876250 100644 --- a/backend/druks/ui/fields.py +++ b/backend/druks/ui/fields.py @@ -8,6 +8,8 @@ class Option(Schema): value: str label: str + # Empty: a flat choice. Set: the shell nests this option in an ````. + group: str = "" def __init__(self, label: str, **data): super().__init__(label=label, **data) @@ -37,6 +39,8 @@ class TextAreaField(PageField): value: str = "" placeholder: str = "" rows: int = 4 + # The value is markdown source. The shell renders it as formatted text. + markdown: bool = False class NumberField(PageField): diff --git a/backend/druks/ui/page.py b/backend/druks/ui/page.py index 68071716..9e82fd14 100644 --- a/backend/druks/ui/page.py +++ b/backend/druks/ui/page.py @@ -94,8 +94,10 @@ def is_static(self) -> bool: def check(self, app_name: str) -> None: """Everything this page decides on its own: its catch-all sits last, and - it takes one name-callable parameter for each parameter of its route. A - child inherits its parent's; an extra one comes from the child path.""" + it takes one required name-callable parameter for each parameter of its + route. Extra parameters must have defaults — they are query filters. A + child inherits its parent's; an extra required one comes from the child + path.""" if any(":path}" in segment for segment in self.route.split("/")[:-1]): raise PageRouteError( f"app {app_name!r} routes {self.name!r} at {self.route!r}, and its catch-all " @@ -113,12 +115,22 @@ def check(self, app_name: str) -> None: by_name = { name for name, parameter in declared.items() if parameter.kind in _CALLABLE_BY_NAME } - if set(declared) == route_parameters and by_name == route_parameters: + if set(declared) != by_name: + raise PageRouteError( + f"app {app_name!r} page {self.name!r} takes {sorted(declared)}, and its route " + f"{self.route!r} carries {sorted(route_parameters)}. Take one parameter for " + "each route parameter, each one callable by name." + ) + required = { + name for name, parameter in declared.items() if parameter.default is Parameter.empty + } + if required == route_parameters: return raise PageRouteError( f"app {app_name!r} page {self.name!r} takes {sorted(declared)}, and its route " f"{self.route!r} carries {sorted(route_parameters)}. Take one parameter for " - "each route parameter, each one callable by name." + "each route parameter, each one callable by name. Extra parameters must have " + "defaults — they are query filters." ) @property @@ -164,11 +176,12 @@ def list_pages_for_app(app_name: str, package: str) -> list[PageRoute]: return [] landing = [page_route for page_route in declared if page_route.route == "/"] - if len(landing) != 1: + if len(landing) > 1: named = sorted(page_route.name for page_route in landing) raise PageRouteError( f"app {app_name!r} declares {len(landing)} pages at '/' ({named}). Declare " - "exactly one: it is the page the app opens on." + "at most one: it is the page the app opens on. An app whose shell home is " + "a React route may declare none." ) by_name: dict[str, PageRoute] = {} diff --git a/backend/druks/ui/schemas.py b/backend/druks/ui/schemas.py index 86084c5f..e36c1b0a 100644 --- a/backend/druks/ui/schemas.py +++ b/backend/druks/ui/schemas.py @@ -5,6 +5,7 @@ from druks.schemas import Schema from .blocks import Action, Block, Link, Watched +from .fields import Field as PageField class Page(Schema): @@ -14,6 +15,7 @@ class Page(Schema): title: str description: str = "" controls: list[Action | Link] = Field(default_factory=list) + filters: list[PageField] = Field(default_factory=list) blocks: list[Block] = Field(default_factory=list) follows: Watched = None diff --git a/backend/migrations/versions/6f4fe68ac2a2_merge_issues_tracker_onto_current_main.py b/backend/migrations/versions/6f4fe68ac2a2_merge_issues_tracker_onto_current_main.py new file mode 100644 index 00000000..0847a27e --- /dev/null +++ b/backend/migrations/versions/6f4fe68ac2a2_merge_issues_tracker_onto_current_main.py @@ -0,0 +1,21 @@ +"""Join the issues-tracker chain onto current main. + +Revision ID: 6f4fe68ac2a2 +Revises: b43924bf37db, b8f3c6d1a047 +Create Date: 2026-09-11 +""" + +from collections.abc import Sequence + +revision: str = "6f4fe68ac2a2" +down_revision: str | Sequence[str] | None = ("b43924bf37db", "b8f3c6d1a047") +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/backend/migrations/versions/a3c9e1f4b072_allow_digit_project_prefixes.py b/backend/migrations/versions/a3c9e1f4b072_allow_digit_project_prefixes.py new file mode 100644 index 00000000..86c3d29c --- /dev/null +++ b/backend/migrations/versions/a3c9e1f4b072_allow_digit_project_prefixes.py @@ -0,0 +1,26 @@ +"""Allow a two-letter prefix plus a digit 1-9. + +Revision ID: a3c9e1f4b072 +Revises: f2b8d5c0e394 +Create Date: 2026-09-10 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "a3c9e1f4b072" +down_revision: str | Sequence[str] | None = "f2b8d5c0e394" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_PREFIX_SHAPE = "(prefix IS NULL) OR (prefix ~ '^([A-Z]{2,6}|[A-Z]{2}[1-9])$')" + + +def upgrade() -> None: + op.drop_constraint("projects_prefix_shape", "projects", type_="check") + op.create_check_constraint("projects_prefix_shape", "projects", _PREFIX_SHAPE) + + +def downgrade() -> None: + raise NotImplementedError("Digit prefixes stay legal.") diff --git a/backend/migrations/versions/b8f3c6d1a047_drop_cancelled_status.py b/backend/migrations/versions/b8f3c6d1a047_drop_cancelled_status.py new file mode 100644 index 00000000..1b1e947d --- /dev/null +++ b/backend/migrations/versions/b8f3c6d1a047_drop_cancelled_status.py @@ -0,0 +1,23 @@ +"""Move leftover Cancelled tickets onto Done. + +Revision ID: b8f3c6d1a047 +Revises: a3c9e1f4b072 +Create Date: 2026-09-10 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "b8f3c6d1a047" +down_revision: str | Sequence[str] | None = "a3c9e1f4b072" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("UPDATE issues_tickets SET status = 'done' WHERE status = 'cancelled'") + + +def downgrade() -> None: + raise NotImplementedError("Cancelled is no longer a status.") diff --git a/backend/migrations/versions/c3f8a1d6e247_issues_projects_tickets_comments.py b/backend/migrations/versions/c3f8a1d6e247_issues_projects_tickets_comments.py new file mode 100644 index 00000000..65da4e0b --- /dev/null +++ b/backend/migrations/versions/c3f8a1d6e247_issues_projects_tickets_comments.py @@ -0,0 +1,65 @@ +"""Software Factory local board: projects, tickets, and comments. + +Revision ID: c3f8a1d6e247 +Revises: a8d4c1e63f92 +Create Date: 2026-09-07 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "c3f8a1d6e247" +down_revision: str | Sequence[str] | None = "a8d4c1e63f92" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "issues_projects", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("prefix", sa.String(length=6), nullable=False), + sa.Column("ticket_seq", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint("prefix ~ '^[A-Z]{2,6}$'", name="issues_projects_prefix_shape"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + sa.UniqueConstraint("prefix"), + ) + op.create_table( + "issues_tickets", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("identifier", sa.String(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column("priority", sa.String(), nullable=False), + sa.Column("project_id", sa.Integer(), nullable=False), + sa.Column("assignee_id", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["project_id"], ["issues_projects.id"]), + sa.ForeignKeyConstraint(["assignee_id"], ["accounts.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("identifier"), + ) + op.create_table( + "issues_comments", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("ticket_id", sa.Integer(), nullable=False), + sa.Column("author_id", sa.String(), nullable=False), + sa.Column("body", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["ticket_id"], ["issues_tickets.id"]), + sa.ForeignKeyConstraint(["author_id"], ["accounts.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("issues_comments") + op.drop_table("issues_tickets") + op.drop_table("issues_projects") diff --git a/backend/migrations/versions/d4a9e2b8c173_tickets_select_a_repo.py b/backend/migrations/versions/d4a9e2b8c173_tickets_select_a_repo.py new file mode 100644 index 00000000..490bf856 --- /dev/null +++ b/backend/migrations/versions/d4a9e2b8c173_tickets_select_a_repo.py @@ -0,0 +1,59 @@ +"""Tickets select a GitHub repo; prefix lives on Project. + +Revision ID: d4a9e2b8c173 +Revises: c3f8a1d6e247 +Create Date: 2026-09-08 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "d4a9e2b8c173" +down_revision: str | Sequence[str] | None = "c3f8a1d6e247" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Unreleased stack: nothing in production to map from IssuesProject onto a + # repo, so drop the ticket rows rather than invent a routing they never had. + op.execute(sa.text("DELETE FROM issues_comments")) + op.execute(sa.text("DELETE FROM issues_tickets")) + op.drop_constraint("issues_tickets_project_id_fkey", "issues_tickets", type_="foreignkey") + op.drop_column("issues_tickets", "project_id") + op.add_column("issues_tickets", sa.Column("repo_id", sa.Integer(), nullable=False)) + op.create_foreign_key( + "issues_tickets_repo_id_fkey", + "issues_tickets", + "project_repos", + ["repo_id"], + ["id"], + ) + op.add_column("issues_tickets", sa.Column("creator_id", sa.String(), nullable=True)) + op.create_foreign_key( + "issues_tickets_creator_id_fkey", + "issues_tickets", + "accounts", + ["creator_id"], + ["id"], + ondelete="RESTRICT", + ) + op.drop_table("issues_projects") + + op.add_column("projects", sa.Column("prefix", sa.String(length=6), nullable=True)) + op.add_column( + "projects", + sa.Column("ticket_seq", sa.Integer(), nullable=False, server_default="0"), + ) + op.create_unique_constraint("projects_prefix_key", "projects", ["prefix"]) + op.create_check_constraint( + "projects_prefix_shape", + "projects", + "(prefix IS NULL) OR (prefix ~ '^[A-Z]{2,6}$')", + ) + + +def downgrade() -> None: + raise NotImplementedError("Ticket-to-repo routing is forward-only.") diff --git a/backend/migrations/versions/e1a7c4b9d283_drop_todo_status.py b/backend/migrations/versions/e1a7c4b9d283_drop_todo_status.py new file mode 100644 index 00000000..ab7c88a7 --- /dev/null +++ b/backend/migrations/versions/e1a7c4b9d283_drop_todo_status.py @@ -0,0 +1,23 @@ +"""Move leftover Todo tickets onto Backlog. + +Revision ID: e1a7c4b9d283 +Revises: d4a9e2b8c173 +Create Date: 2026-09-10 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "e1a7c4b9d283" +down_revision: str | Sequence[str] | None = "d4a9e2b8c173" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("UPDATE issues_tickets SET status = 'backlog' WHERE status = 'todo'") + + +def downgrade() -> None: + raise NotImplementedError("Todo is no longer a status.") diff --git a/backend/migrations/versions/f2b8d5c0e394_rename_ticket_assignee_to_owner.py b/backend/migrations/versions/f2b8d5c0e394_rename_ticket_assignee_to_owner.py new file mode 100644 index 00000000..c197a954 --- /dev/null +++ b/backend/migrations/versions/f2b8d5c0e394_rename_ticket_assignee_to_owner.py @@ -0,0 +1,23 @@ +"""Rename ticket assignee_id to owner_id. + +Revision ID: f2b8d5c0e394 +Revises: e1a7c4b9d283 +Create Date: 2026-09-10 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "f2b8d5c0e394" +down_revision: str | Sequence[str] | None = "e1a7c4b9d283" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.alter_column("issues_tickets", "assignee_id", new_column_name="owner_id") + + +def downgrade() -> None: + raise NotImplementedError("Owner is the name of this column.") diff --git a/backend/tests/software_factory/test_build_dispatch.py b/backend/tests/software_factory/test_build_dispatch.py index 88776817..ce1a35f5 100644 --- a/backend/tests/software_factory/test_build_dispatch.py +++ b/backend/tests/software_factory/test_build_dispatch.py @@ -1,11 +1,12 @@ from datetime import UTC, datetime from conftest import connect_service +from druks.contrib.software_factory.contracts import ReviewWork from druks.contrib.software_factory.workflows import Build from druks.signals import publish from druks.testing import seed_run -from software_factory.factories import make_test_work_item +from software_factory.factories import make_test_work_item, seed_build_run async def _connect_github() -> None: @@ -124,6 +125,22 @@ async def fake_start(cls, **kwargs): assert any("already merged" in record.getMessage() for record in caplog.records) +async def test_dispatch_syncs_a_parked_build_instead_of_restarting(druks_db, monkeypatch) -> None: + await _connect_github() + item = await make_test_work_item(repo="o/r", title="t", ticket_key="ACME-12") + await seed_build_run(druks_db, work_item_id=item.id, state="parked", input_gate=ReviewWork.name) + started = [] + + async def fake_start(cls, **kwargs): + started.append(kwargs) + return "should-not-run" + + monkeypatch.setattr(Build, "start", classmethod(fake_start)) + + assert await Build.dispatch(ticket=_ticket(item)) is None + assert started == [] + + async def test_dispatch_unroutable_noop_still_precedes_the_identity_guard( druks_db, monkeypatch, caplog ) -> None: diff --git a/backend/tests/software_factory/test_build_prompts.py b/backend/tests/software_factory/test_build_prompts.py index 0fd54bef..72d16fe0 100644 --- a/backend/tests/software_factory/test_build_prompts.py +++ b/backend/tests/software_factory/test_build_prompts.py @@ -7,7 +7,7 @@ from druks.contrib.software_factory.journal import BuildJournal from druks.contrib.software_factory.models import Project, ProjectRepo from druks.contrib.software_factory.policy import RepoPolicy -from druks.contrib.software_factory.prompt_context import BuildPromptContext +from druks.contrib.software_factory.prompt_context import TRACKER_LABELS, BuildPromptContext from druks.prompts import render_prompt from druks.workflows import FatalError @@ -26,16 +26,18 @@ } -def _build(*, review_code: bool = True) -> SimpleNamespace: +def _build(**overrides) -> SimpleNamespace: """A stand-in BuildPromptContext exposing the fields the templates read — identity facts faked, the journal real and empty.""" - return SimpleNamespace( + source = overrides.get("source", "github") + fields = dict( repo="acme/widget", work_item_url="https://druks.test/work-items/1", branch="agent/eng-1", pr_number=7, ticket_ref="ACME-1", - source="github", + source=source, + tracker_label=TRACKER_LABELS[source], issue_number=None, task_owner_name=None, task_owner_email=None, @@ -46,10 +48,14 @@ def _build(*, review_code: bool = True) -> SimpleNamespace: description="Apply the Python house rules.", ) ], - review_code=review_code, + review_code=True, review_mode="approve", journal=BuildJournal(), ) + fields.update(overrides) + if "tracker_label" not in overrides: + fields["tracker_label"] = TRACKER_LABELS[fields["source"]] + return SimpleNamespace(**fields) def _workspace() -> SimpleNamespace: @@ -71,11 +77,11 @@ async def test_build_operation_prompt_renders(template): async def _generate_plan_prompt( - *, answered_questions=None, operator_note="", reviewer_notes="" + *, answered_questions=None, operator_note="", reviewer_notes="", **build ) -> str: return await render_prompt( "software_factory/build/generate_plan.md", - build=_build(), + build=_build(**build), verification="VERIFICATION-BLOCK", workspace=_workspace(), answered_questions=answered_questions or [], @@ -116,6 +122,25 @@ async def test_generate_plan_prompt_keeps_first_draft_ambiguity_instructions(): assert "Before deep code reading" in prompt +async def test_issues_ticket_fetch_names_the_druks_tool(): + prompt = await _generate_plan_prompt(source="issues", ticket_ref="BOX-3") + + assert "`software_factory_get_ticket` on the `druks` MCP with identifier `BOX-3`" in prompt + assert "BOX-3` is not a GitHub issue number" in prompt + assert "Ticket:** BOX-3 on the Druks board" in prompt + assert "`software_factory_add_comment` on the `druks` MCP (identifier `BOX-3`)" in prompt + assert "from Issues using your available tools" not in prompt + assert "from the Druks board using your tracker tools" not in prompt + + +async def test_linear_ticket_fetch_names_linear(): + prompt = await _generate_plan_prompt(source="linear", ticket_ref="ACME-1") + + assert "fetch `ACME-1` from Linear using your tracker tools" in prompt + assert "software_factory_get_ticket" not in prompt + assert "Ticket:** ACME-1 on Linear" in prompt + + async def test_verification_profile_renders_ci_provenance_per_command(): block = await RepoPolicy().verification_block( profile={ diff --git a/backend/tests/software_factory/test_build_workspace.py b/backend/tests/software_factory/test_build_workspace.py index 653c6f7a..f811b4e3 100644 --- a/backend/tests/software_factory/test_build_workspace.py +++ b/backend/tests/software_factory/test_build_workspace.py @@ -7,13 +7,20 @@ import pytest from conftest import connect_service 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.services import GithubReviewer from druks.contrib.software_factory.workflows import Build, BuildWorkspace, ReviewWorkspace from druks.core.services import Github from druks.mcp.helpers import get_bearer_token_env_var from druks.sandbox import host as host_mod from druks.sandbox.layout import get_related_root, get_repo_root +from druks.workflows import FatalError from druks.workspaces import RepoWorkspace @@ -109,6 +116,121 @@ async def test_get_workspace_kwargs_carries_the_build_fields(): } +async def test_issues_tracker_requires_appliance_mcp(druks_db): + await connect_service( + "github", identity={"app_id": "1", "slug": "druks-operator"}, secrets={"private_key": "pem"} + ) + await connect_service( + "github_reviewer", + identity={"app_id": "2", "slug": "druks-reviewer"}, + secrets={"private_key": "reviewer-pem"}, + ) + workspace = BuildWorkspace( + host=_FakeSandbox(), # type: ignore[arg-type] + subject=SimpleNamespace(repo="o/main"), + branch="b", + 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 _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]: + _pin_tracker(monkeypatch, "issues") + monkeypatch.setattr( + "druks.contrib.software_factory.workflows.load_settings", + lambda: SimpleNamespace(urls=SimpleNamespace(endpoint="http://127.0.0.1:8001")), + ) + 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_") + + +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): + 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) + + assert "appliance_mcp_url" not in kwargs + assert "appliance_mcp_token" not in kwargs + + def _review_actor_stub(monkeypatch: pytest.MonkeyPatch, *, review_actor) -> None: async def _review_actor(): return review_actor() diff --git a/backend/tests/software_factory/test_issues_models.py b/backend/tests/software_factory/test_issues_models.py new file mode 100644 index 00000000..baff9891 --- /dev/null +++ b/backend/tests/software_factory/test_issues_models.py @@ -0,0 +1,183 @@ +import pytest +from druks.accounts.models import Account +from druks.apps.loader import iter_apps +from druks.contrib.software_factory.exceptions import ( + InvalidPrefix, + MissingPrefix, + PrefixLocked, + PrefixTaken, + ProjectNotFound, + RepoNotFound, +) +from druks.contrib.software_factory.issues.enums import Status +from druks.contrib.software_factory.issues.models import Comment, Ticket +from druks.contrib.software_factory.models import Project, ProjectRepo, prefix_candidates +from sqlalchemy.exc import IntegrityError + + +async def _open_repo(*, name="Acme", prefix="dru", full_name="acme/druks"): + project = await Project.create(name=name, prefix=prefix) + return await ProjectRepo.create(project_id=project.id, full_name=full_name) + + +def test_issues_is_not_a_bundled_app(): + assert "issues" not in {app.name for app in iter_apps()} + + +async def test_ticket_identifiers_are_monotonic_per_project_and_never_reused(): + dru = await _open_repo(name="Druks", prefix="dru", full_name="acme/druks") + first = await Ticket.create(repo_id=dru.id, title="one") + second = await Ticket.create(repo_id=dru.id, title="two") + assert first.identifier == "DRU-1" + assert second.identifier == "DRU-2" + + await first.delete() + third = await Ticket.create(repo_id=dru.id, title="three") + assert third.identifier == "DRU-3" + + eng = await _open_repo(name="Engine", prefix="eng", full_name="acme/engine") + other = await Ticket.create(repo_id=eng.id, title="eng-first") + assert other.identifier == "ENG-1" + + +async def test_unknown_repo_refuses_a_ticket(): + with pytest.raises(RepoNotFound): + await Ticket.create(repo_id=0, title="orphan") + + +async def test_a_project_without_a_prefix_refuses_a_ticket(): + project = await Project.create(name="A") + repo = await ProjectRepo.create(project_id=project.id, full_name="acme/bare") + with pytest.raises(MissingPrefix): + await Ticket.create(repo_id=repo.id, title="orphan") + with pytest.raises(ProjectNotFound): + await Project.mint_identifier(0) + + +async def test_duplicate_project_names_fail(): + await Project.create(name="alpha", prefix="alp") + with pytest.raises(IntegrityError): + await Project.create(name="alpha", prefix="bet") + + +async def test_duplicate_project_prefixes_fail(): + await Project.create(name="alpha", prefix="alp") + with pytest.raises(PrefixTaken, match="ALP"): + await Project.create(name="other", prefix="alp") + + +async def test_set_prefix_refuses_a_prefix_another_project_holds(): + await Project.create(name="BOX", prefix="box") + acme = await Project.create(name="Acme") + with pytest.raises(PrefixTaken, match="BOX"): + await acme.set_prefix("box") + + +async def test_prefix_must_be_two_to_six_letters_or_two_letters_and_a_digit(): + with pytest.raises(InvalidPrefix): + await Project.create(name="short", prefix="A") + with pytest.raises(InvalidPrefix): + await Project.create(name="zero", prefix="DR0") + with pytest.raises(InvalidPrefix): + await Project.create(name="long", prefix="ABCDEFG") + project = await Project.create(name="digits", prefix="DR1") + assert project.prefix == "DR1" + + +def test_prefix_candidates_walk_letters_then_digits(): + assert prefix_candidates("Acme") == [ + "ACM", + "ACE", + "AC1", + "AC2", + "AC3", + "AC4", + "AC5", + "AC6", + "AC7", + "AC8", + "AC9", + ] + assert prefix_candidates("Go") == [f"GO{digit}" for digit in range(1, 10)] + assert prefix_candidates("A") == [] + assert prefix_candidates("Acme Tools")[0] == "ACM" + assert prefix_candidates("Acme Tools")[1] == "ACE" + + +async def test_create_walks_derived_prefixes_on_clash(): + first = await Project.create(name="Acme") + assert first.prefix == "ACM" + second = await Project.create(name="Acme Tools") + assert second.prefix == "ACE" + third = await Project.create(name="Go") + assert third.prefix == "GO1" + fourth = await Project.create(name="Go 2") + assert fourth.prefix == "GO2" + + +async def test_prefix_cannot_change_after_a_ticket_is_minted(): + project = await Project.create(name="locked", prefix="lok") + repo = await ProjectRepo.create(project_id=project.id, full_name="acme/locked") + await project.set_prefix("lokx") + assert project.prefix == "LOKX" + + await Ticket.create(repo_id=repo.id, title="minted") + with pytest.raises(PrefixLocked): + await project.set_prefix("newpre") + assert (await Project.get(project.id)).prefix == "LOKX" + + +async def test_comments_are_rows_and_empty_is_a_list(): + account = await Account.get_or_create("op@example.com") + repo = await _open_repo(name="Thread", prefix="thd", full_name="acme/thread") + ticket = await Ticket.create(repo_id=repo.id, title="quiet") + + assert await ticket.list_comments() == [] + + first = await ticket.add_comment(author_id=account.id, body="first") + second = await ticket.add_comment(author_id=account.id, body="second") + listed = await ticket.list_comments() + assert [comment.body for comment in listed] == ["first", "second"] + assert listed[0].id == first.id + assert listed[1].id == second.id + assert all(isinstance(comment, Comment) for comment in listed) + + +async def test_list_board_includes_blocked(): + repo = await _open_repo(name="Board", prefix="brd", full_name="acme/board") + live = await Ticket.create(repo_id=repo.id, title="live") + stuck = await Ticket.create(repo_id=repo.id, title="stuck") + await stuck.set_status(Status.BLOCKED) + + board = await Ticket.list_board() + identifiers = {ticket.identifier for ticket in board} + assert live.identifier in identifiers + assert stuck.identifier in identifiers + found = await Ticket.get_for_identifier(live.identifier) + assert found is not None + assert found.id == live.id + assert found.get_label() == live.identifier + assert found.get_summary().title == "live" + + +async def test_list_matching_filters_by_owner_creator_and_repo(): + account = await Account.get_or_create("op@example.com") + dru = await _open_repo(name="Filter", prefix="flt", full_name="acme/filter") + other = await _open_repo(name="Other", prefix="oth", full_name="acme/other") + await Ticket.create(repo_id=dru.id, title="mine", owner_id=account.id, creator_id=account.id) + await Ticket.create(repo_id=dru.id, title="open") + await Ticket.create(repo_id=other.id, title="elsewhere") + + assert {ticket.title for ticket in await Ticket.list_matching(owner="none")} == { + "open", + "elsewhere", + } + assert {ticket.title for ticket in await Ticket.list_matching(owner=account.id)} == {"mine"} + assert {ticket.title for ticket in await Ticket.list_matching(creator=account.id)} == {"mine"} + assert {ticket.title for ticket in await Ticket.list_matching(repo_id=other.id)} == { + "elsewhere" + } + assert {ticket.title for ticket in await Ticket.list_matching(project_id=dru.project_id)} == { + "mine", + "open", + } diff --git a/backend/tests/software_factory/test_issues_pages.py b/backend/tests/software_factory/test_issues_pages.py new file mode 100644 index 00000000..7d8abb8d --- /dev/null +++ b/backend/tests/software_factory/test_issues_pages.py @@ -0,0 +1,331 @@ +from druks.accounts.models import Account +from druks.contrib.software_factory.issues.models import Ticket + +from software_factory.factories import make_test_work_item + +BOARD_COLUMNS = [ + "Backlog", + "Ready for Agent", + "In Progress", + "Blocked", + "In Review", + "Done", +] + +_PAGES = "/api/software_factory/pages" +_TICKETS = "/api/software_factory/tickets" +_PROJECTS = "/api/software_factory/projects" + + +async def _open_repo(druks_client, *, project="Acme", prefix="dru", repo="acme/druks"): + created = await druks_client.post(_PROJECTS, json={"name": project, "prefix": prefix}) + assert created.status_code == 201 + added = await druks_client.post( + f"{_PROJECTS}/{created.json()['id']}/repos", + json={"fullName": repo}, + ) + assert added.status_code == 201 + return added.json() + + +async def _open_ticket(druks_client, repo_id, **fields): + created = await druks_client.post( + _TICKETS, + json={"title": "one", "repo_id": int(repo_id), **fields}, + ) + assert created.status_code == 201 + return created.json() + + +def _columns(page: dict) -> list[dict]: + return page["blocks"][0]["blocks"] + + +def _cards_in(column: dict) -> list[dict]: + return column["blocks"][0]["cards"] + + +def _comments(page: dict) -> dict: + left = page["blocks"][0]["blocks"][0]["blocks"] + return next(block for block in left if block.get("name") == "comments") + + +async def test_empty_board_shows_columns_and_create_actions(druks_client): + page = (await druks_client.get(f"{_PAGES}/board")).json() + + assert page["title"] == "Board" + assert page["description"] == "" + assert [field["name"] for field in page["filters"]] == [ + "status", + "priority", + "updated", + "owner", + "creator", + "project", + "repo", + ] + assert [control["label"] for control in page["controls"]] == ["New ticket"] + assert [control["operation"] for control in page["controls"]] == ["create_ticket"] + assert page["controls"][0]["fields"][1]["name"] == "repo_id" + me = (await druks_client.get("/api/auth/me")).json()["account"]["id"] + owner = next(field for field in page["controls"][0]["fields"] if field["name"] == "owner_id") + assert owner["label"] == "Owner" + assert owner["value"] == me + assert owner["options"][0]["label"] == "Unowned" + columns = _columns(page) + assert [column["title"] for column in columns] == BOARD_COLUMNS + for column in columns: + cards = column["blocks"][0] + assert cards["layout"] == "stack" + assert cards["drop"]["operation"] == "set_status" + assert cards["drop"]["refresh"] == "page" + assert cards["cards"] == [] + assert cards["empty"]["title"] == "Nothing here" + assert [column["blocks"][0]["drop"]["arguments"]["status"] for column in columns] == [ + "backlog", + "ready_for_agent", + "in_progress", + "blocked", + "in_review", + "done", + ] + status_filter = next(field for field in page["filters"] if field["name"] == "status") + assert [option["label"] for option in status_filter["options"]] == ["Any", *BOARD_COLUMNS] + create_status = next( + field for field in page["controls"][0]["fields"] if field["name"] == "status" + ) + assert [option["label"] for option in create_status["options"]] == BOARD_COLUMNS + + +async def test_created_ticket_lands_in_backlog_on_the_board(druks_client): + repo = await _open_repo(druks_client) + ticket = await _open_ticket(druks_client, repo["id"], title="Ship the board") + + board = (await druks_client.get(f"{_PAGES}/board")).json() + by_title = {column["title"]: column for column in _columns(board)} + (card,) = _cards_in(by_title["Backlog"]) + assert card["title"] == "Ship the board" + assert card["description"].startswith("DRU-1") + assert card["link"]["arguments"] == {"identifier": ticket["identifier"]} + assert card["drag"] == {"identifier": ticket["identifier"]} + assert card["controls"] == [] + for title in BOARD_COLUMNS: + if title != "Backlog": + assert _cards_in(by_title[title]) == [] + + +async def test_moving_a_ticket_updates_the_board(druks_client): + repo = await _open_repo(druks_client) + ticket = await _open_ticket(druks_client, repo["id"], title="In flight") + moved = await druks_client.post( + f"{_TICKETS}/{ticket['identifier']}/status", + json={"status": "in_progress"}, + ) + assert moved.status_code == 200 + + board = (await druks_client.get(f"{_PAGES}/board")).json() + by_title = {column["title"]: column for column in _columns(board)} + assert [card["title"] for card in _cards_in(by_title["In Progress"])] == ["In flight"] + assert _cards_in(by_title["Backlog"]) == [] + + +async def test_blocked_tickets_stay_on_the_board(druks_client): + repo = await _open_repo(druks_client) + await _open_ticket(druks_client, repo["id"], title="live") + stuck = await _open_ticket(druks_client, repo["id"], title="stuck") + moved = await druks_client.post( + f"{_TICKETS}/{stuck['identifier']}/status", + json={"status": "blocked"}, + ) + assert moved.status_code == 200 + + board = (await druks_client.get(f"{_PAGES}/board")).json() + by_title = {column["title"]: column for column in _columns(board)} + assert [card["title"] for card in _cards_in(by_title["Blocked"])] == ["stuck"] + assert [card["title"] for card in _cards_in(by_title["Backlog"])] == ["live"] + + +async def test_ticket_page_follows_the_row_and_comments_refresh_the_region(druks_client): + repo = await _open_repo(druks_client) + created = await _open_ticket(druks_client, repo["id"], title="Follow me") + row = await Ticket.get_for_identifier(created["identifier"]) + + page = (await druks_client.get(f"{_PAGES}/tickets/{created['identifier']}")).json() + + assert page["title"] == created["identifier"] + assert page["follows"] == {"subjectType": "ticket", "subjectId": str(row.id)} + assert page["controls"] == [] + columns = page["blocks"][0] + assert columns["layout"] == "sidebar" + left = columns["blocks"][0]["blocks"] + prose = left[0] + assert prose["submit"] == "change" + assert prose["layout"] == "prose" + assert prose["fields"][0]["value"] == "Follow me" + assert prose["action"]["operation"] == "update_ticket" + status = columns["blocks"][1]["blocks"][0] + assert status["action"]["operation"] == "set_status" + assert status["submit"] == "change" + owner = columns["blocks"][1]["blocks"][2] + assert owner["fields"][0]["name"] == "owner_id" + assert owner["fields"][0]["label"] == "Owner" + repo = columns["blocks"][1]["blocks"][3] + assert repo["fields"][0]["name"] == "repo_id" + assert repo["fields"][0]["options"][0]["group"] == "Acme" + facts = columns["blocks"][1]["blocks"][-1] + assert [fact["label"] for fact in facts["facts"]] == [ + "Identifier", + "Created by", + "Created", + "Updated", + ] + assert facts["facts"][0]["value"]["text"] == created["identifier"] + account = await Account.get_or_create("op@example.com") + assert facts["facts"][1]["value"]["text"] == account.username + assert facts["facts"][2]["value"]["value"] == "time" + assert facts["facts"][3]["value"]["value"] == "time" + comments = _comments(page) + assert comments["title"] == "Comments" + assert comments["blocks"][0]["title"] == "No comments yet" + comment_form = comments["blocks"][1] + assert comment_form["action"]["operation"] == "add_comment" + assert comment_form["action"]["refresh"] == "region" + + written = await druks_client.post( + f"{_TICKETS}/{created['identifier']}/comments", + json={"body": "looks good"}, + ) + assert written.status_code == 201 + + after = (await druks_client.get(f"{_PAGES}/tickets/{created['identifier']}")).json() + thread = _comments(after) + assert thread["blocks"][0]["blocks"][0]["text"] == "looks good" + + +async def test_ticket_page_unattributed_creator_when_none_is_stored(druks_client): + repo = await _open_repo(druks_client) + ticket = await Ticket.create(repo_id=int(repo["id"]), title="ghost") + + page = (await druks_client.get(f"{_PAGES}/tickets/{ticket.identifier}")).json() + + facts = page["blocks"][0]["blocks"][1]["blocks"][-1] + created_by = next(fact for fact in facts["facts"] if fact["label"] == "Created by") + assert created_by["value"]["text"] == "Unattributed" + + +async def test_ticket_page_links_the_open_build(druks_client): + repo = await _open_repo(druks_client) + created = await _open_ticket(druks_client, repo["id"], title="Follow me") + item = await make_test_work_item( + repo="acme/druks", + source="issues", + ticket_key=created["identifier"], + title="Follow me", + ) + + page = (await druks_client.get(f"{_PAGES}/tickets/{created['identifier']}")).json() + + assert page["controls"] == [ + { + "block": "link", + "label": "Open build", + "page": "", + "arguments": {}, + "url": f"/software_factory/work-items/{item.id}", + "subject": None, + } + ] + + +async def test_new_ticket_groups_repos_by_github_project(druks_client): + acme = (await druks_client.post(_PROJECTS, json={"name": "Acme", "prefix": "acm"})).json() + one = ( + await druks_client.post(f"{_PROJECTS}/{acme['id']}/repos", json={"fullName": "acme/one"}) + ).json() + two = ( + await druks_client.post(f"{_PROJECTS}/{acme['id']}/repos", json={"fullName": "acme/two"}) + ).json() + beta = await _open_repo(druks_client, project="Beta", prefix="bet", repo="beta/app") + + board = (await druks_client.get(f"{_PAGES}/board")).json() + repo_field = next( + field for field in board["controls"][0]["fields"] if field["name"] == "repo_id" + ) + assert [option["group"] for option in repo_field["options"]] == ["Acme", "Acme", "Beta"] + assert [option["label"] for option in repo_field["options"]] == [ + "acme/one", + "acme/two", + "beta/app", + ] + assert [option["value"] for option in repo_field["options"]] == [ + str(one["id"]), + str(two["id"]), + str(beta["id"]), + ] + assert repo_field["value"] == str(one["id"]) + + +async def test_unknown_ticket_page_is_an_empty_state(druks_client): + page = (await druks_client.get(f"{_PAGES}/tickets/NOPE-1")).json() + + assert page["blocks"][0]["title"] == "No such ticket" + assert page["blocks"][0]["controls"][0]["page"] == "board" + + +async def test_roster_names_the_board_and_ticket_pages(druks_client): + roster = {entry["name"]: entry for entry in (await druks_client.get("/api/apps")).json()} + + names = [page["name"] for page in roster["software_factory"]["pages"]] + assert names == ["board", "ticket"] + assert roster["software_factory"]["navigation"] == [] + + +async def test_board_status_filter_keeps_columns(druks_client): + repo = await _open_repo(druks_client) + await _open_ticket(druks_client, repo["id"], title="live") + stuck = await _open_ticket(druks_client, repo["id"], title="stuck") + await druks_client.post( + f"{_TICKETS}/{stuck['identifier']}/status", + json={"status": "blocked"}, + ) + + backlog = (await druks_client.get(f"{_PAGES}/board", params={"status": "backlog"})).json() + cards = [card["title"] for column in _columns(backlog) for card in _cards_in(column)] + assert cards == ["live"] + assert [column["title"] for column in _columns(backlog)] == BOARD_COLUMNS + + blocked = (await druks_client.get(f"{_PAGES}/board", params={"status": "blocked"})).json() + assert [column["title"] for column in _columns(blocked)] == BOARD_COLUMNS + cards = [card["title"] for column in _columns(blocked) for card in _cards_in(column)] + assert cards == ["stuck"] + + +async def test_board_filters_by_repo_owner_and_creator(druks_client): + acme = (await druks_client.post(_PROJECTS, json={"name": "Acme", "prefix": "acm"})).json() + acme_repo = ( + await druks_client.post(f"{_PROJECTS}/{acme['id']}/repos", json={"fullName": "acme/one"}) + ).json() + beta_repo = await _open_repo(druks_client, project="Beta", prefix="bet", repo="beta/app") + me = (await druks_client.get("/api/auth/me")).json()["account"]["id"] + await _open_ticket(druks_client, acme_repo["id"], title="assigned", owner_id=me) + await _open_ticket(druks_client, acme_repo["id"], title="open") + await _open_ticket(druks_client, beta_repo["id"], title="elsewhere") + + by_repo = (await druks_client.get(f"{_PAGES}/board", params={"repo": beta_repo["id"]})).json() + assert [card["title"] for column in _columns(by_repo) for card in _cards_in(column)] == [ + "elsewhere" + ] + + unowned = (await druks_client.get(f"{_PAGES}/board", params={"owner": "none"})).json() + assert {card["title"] for column in _columns(unowned) for card in _cards_in(column)} == { + "elsewhere", + "open", + } + + mine = (await druks_client.get(f"{_PAGES}/board", params={"creator": me})).json() + titles = [card["title"] for column in _columns(mine) for card in _cards_in(column)] + assert set(titles) == {"assigned", "open", "elsewhere"} + + by_project = (await druks_client.get(f"{_PAGES}/board", params={"project": acme["id"]})).json() + titles = [card["title"] for column in _columns(by_project) for card in _cards_in(column)] + assert set(titles) == {"assigned", "open"} diff --git a/backend/tests/software_factory/test_issues_routes.py b/backend/tests/software_factory/test_issues_routes.py new file mode 100644 index 00000000..984c0f5b --- /dev/null +++ b/backend/tests/software_factory/test_issues_routes.py @@ -0,0 +1,273 @@ +from druks.accounts.models import Account +from druks.api.server import app as api +from druks.contrib.software_factory.issues.enums import Status + +_TICKETS = "/api/software_factory/tickets" +_PROJECTS = "/api/software_factory/projects" + + +def _published(monkeypatch): + events = [] + + async def emit(name, **kwargs): + events.append((name, kwargs["payload"])) + + monkeypatch.setattr("druks.contrib.software_factory.issues.models.publish", emit) + return events + + +async def _open_repo(druks_client, *, project="Acme", prefix="dru", repo="acme/druks"): + created = await druks_client.post(_PROJECTS, json={"name": project, "prefix": prefix}) + assert created.status_code == 201 + added = await druks_client.post( + f"{_PROJECTS}/{created.json()['id']}/repos", + json={"fullName": repo}, + ) + assert added.status_code == 201 + return added.json() + + +async def _open_ticket(druks_client, repo_id, **fields): + created = await druks_client.post( + _TICKETS, + json={"title": "one", "repo_id": int(repo_id), **fields}, + ) + assert created.status_code == 201 + return created.json() + + +def test_get_and_comment_are_agent_operations(): + schema = api.openapi() + get_ticket = schema["paths"][f"{_TICKETS}/{{identifier}}"]["get"] + add_comment = schema["paths"][f"{_TICKETS}/{{identifier}}/comments"]["post"] + assert "agent" in get_ticket["tags"] + assert get_ticket["operationId"] in {"get_ticket", "software_factory_get_ticket"} + assert "agent" in add_comment["tags"] + assert add_comment["operationId"] in {"add_comment", "software_factory_add_comment"} + + +async def test_create_as_backlog_does_not_publish(druks_client, monkeypatch): + events = _published(monkeypatch) + repo = await _open_repo(druks_client) + ticket = await _open_ticket(druks_client, repo["id"], title="quiet") + + assert ticket["identifier"] == "DRU-1" + assert ticket["status"] == "backlog" + assert ticket["comments"] == [] + assert events == [] + + +async def test_create_as_ready_for_agent_publishes_the_trigger(druks_client, monkeypatch): + events = _published(monkeypatch) + repo = await _open_repo(druks_client, repo="acme/acme-app") + ticket = await _open_ticket(druks_client, repo["id"], status="ready_for_agent", title="go") + + assert ticket["status"] == "ready_for_agent" + assert events == [ + ( + "ticket.transitioned", + { + "source": "issues", + "identifier": "DRU-1", + "status": Status.READY_FOR_AGENT.label, + "title": "go", + "url": "/software_factory/tickets/DRU-1", + "project_name": "acme-app", + "labels": [], + "assignee_email": None, + "assignee_name": None, + "completed": False, + "terminal": False, + }, + ) + ] + + +async def test_set_status_publishes_one_transition_with_display_labels(druks_client, monkeypatch): + events = _published(monkeypatch) + repo = await _open_repo(druks_client, repo="acme/acme-app") + ticket = await _open_ticket(druks_client, repo["id"], title="Add an endpoint") + + moved = await druks_client.post( + f"{_TICKETS}/{ticket['identifier']}/status", + json={"status": "ready_for_agent"}, + ) + + assert moved.status_code == 200 + assert moved.json()["status"] == "ready_for_agent" + assert events == [ + ( + "ticket.transitioned", + { + "source": "issues", + "identifier": "DRU-1", + "status": Status.READY_FOR_AGENT.label, + "title": "Add an endpoint", + "url": "/software_factory/tickets/DRU-1", + "project_name": "acme-app", + "labels": [], + "assignee_email": None, + "assignee_name": None, + "completed": False, + "terminal": False, + }, + ) + ] + + again = await druks_client.post( + f"{_TICKETS}/{ticket['identifier']}/status", + json={"status": "ready_for_agent"}, + ) + assert again.status_code == 200 + assert len(events) == 1 + + +async def test_set_status_marks_done_completed_and_terminal(druks_client, monkeypatch): + events = _published(monkeypatch) + repo = await _open_repo(druks_client) + ticket = await _open_ticket(druks_client, repo["id"]) + stuck = await _open_ticket(druks_client, repo["id"]) + + done = await druks_client.post( + f"{_TICKETS}/{ticket['identifier']}/status", + json={"status": "done"}, + ) + blocked = await druks_client.post( + f"{_TICKETS}/{stuck['identifier']}/status", + json={"status": "blocked"}, + ) + + assert done.status_code == 200 + assert blocked.status_code == 200 + assert [payload["status"] for _, payload in events] == ["Done", "Blocked"] + assert [payload["completed"] for _, payload in events] == [True, False] + assert [payload["terminal"] for _, payload in events] == [True, False] + + +async def test_update_ticket_never_publishes_and_cannot_set_status(druks_client, monkeypatch): + events = _published(monkeypatch) + repo = await _open_repo(druks_client) + ticket = await _open_ticket(druks_client, repo["id"], title="old") + + edited = await druks_client.patch( + f"{_TICKETS}/{ticket['identifier']}", + json={"title": "new", "status": "done", "priority": "high"}, + ) + + assert edited.status_code == 200 + body = edited.json() + assert body["title"] == "new" + assert body["priority"] == "high" + assert body["status"] == "backlog" + assert events == [] + + +async def test_blank_owner_is_nobody(druks_client): + repo = await _open_repo(druks_client) + created = await druks_client.post( + _TICKETS, + json={"title": "unheld", "repo_id": int(repo["id"]), "owner_id": ""}, + ) + + assert created.status_code == 201 + assert created.json()["owner_id"] is None + + ticket = created.json() + assigned = await Account.get_or_create("dev@example.com") + held = await druks_client.patch( + f"{_TICKETS}/{ticket['identifier']}", + json={"owner_id": assigned.id}, + ) + assert held.status_code == 200 + assert held.json()["owner_id"] == assigned.id + + cleared = await druks_client.patch( + f"{_TICKETS}/{ticket['identifier']}", + json={"owner_id": ""}, + ) + assert cleared.status_code == 200 + assert cleared.json()["owner_id"] is None + + +async def test_update_can_move_a_ticket_to_another_repo(druks_client): + first = await _open_repo(druks_client, project="Alpha", prefix="alp", repo="acme/alpha") + second = await _open_repo(druks_client, project="Beta", prefix="bet", repo="acme/beta") + ticket = await _open_ticket(druks_client, first["id"]) + + moved = await druks_client.patch( + f"{_TICKETS}/{ticket['identifier']}", + json={"repo_id": int(second["id"])}, + ) + + assert moved.status_code == 200 + assert moved.json()["repo_id"] == int(second["id"]) + assert moved.json()["identifier"] == "ALP-1" + + +async def test_add_comment_authors_from_the_request_account(druks_client): + account = await Account.get_or_create("op@example.com") + repo = await _open_repo(druks_client) + ticket = await _open_ticket(druks_client, repo["id"]) + + written = await druks_client.post( + f"{_TICKETS}/{ticket['identifier']}/comments", + json={"body": "ship it"}, + ) + + assert written.status_code == 201 + comment = written.json() + assert comment["author"] == account.username + assert comment["body"] == "ship it" + + detail = await druks_client.get(f"{_TICKETS}/{ticket['identifier']}") + assert detail.status_code == 200 + assert [line["author"] for line in detail.json()["comments"]] == [account.username] + + +async def test_blank_title_and_body_are_refused(druks_client): + repo = await _open_repo(druks_client) + + created = await druks_client.post( + _TICKETS, + json={"title": " ", "repo_id": int(repo["id"])}, + ) + assert created.status_code == 422 + + ticket = await _open_ticket(druks_client, repo["id"]) + edited = await druks_client.patch( + f"{_TICKETS}/{ticket['identifier']}", + json={"title": " "}, + ) + assert edited.status_code == 422 + + commented = await druks_client.post( + f"{_TICKETS}/{ticket['identifier']}/comments", + json={"body": "\n"}, + ) + assert commented.status_code == 422 + + +async def test_unknown_ticket_and_unknown_owner_are_404(druks_client): + missing = await druks_client.get(f"{_TICKETS}/DRU-99") + assert missing.status_code == 404 + + repo = await _open_repo(druks_client) + assigned = await druks_client.post( + _TICKETS, + json={ + "title": "handed to nobody real", + "repo_id": int(repo["id"]), + "owner_id": "not-an-account", + }, + ) + assert assigned.status_code == 404 + + ticket = await _open_ticket(druks_client, repo["id"]) + updated = await druks_client.patch( + f"{_TICKETS}/{ticket['identifier']}", + json={"owner_id": "not-an-account"}, + ) + assert updated.status_code == 404 + + gone = await druks_client.post(f"{_TICKETS}/NOPE-1/status", json={"status": "done"}) + assert gone.status_code == 404 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..8eb1abad --- /dev/null +++ b/backend/tests/software_factory/test_issues_tracker.py @@ -0,0 +1,133 @@ +import druks.contrib.software_factory.subscribers # noqa: F401 +import pytest +from conftest import connect_service +from druks.contrib.software_factory.app import SoftwareFactory +from druks.contrib.software_factory.contracts import ReviewWork +from druks.contrib.software_factory.issues.enums import Status +from druks.contrib.software_factory.issues.models import Ticket +from druks.contrib.software_factory.models import Project, ProjectRepo, 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 software_factory.factories import make_test_work_item, seed_build_run + + +async def _open_ticket(*, project="Acme", prefix="WID", full_name="acme/widget", title="one"): + row = await Project.create(name=project, prefix=prefix) + repo = await ProjectRepo.create(project_id=row.id, full_name=full_name) + return await Ticket.create(repo_id=repo.id, title=title) + + +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 connect_service( + "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.DONE), + ], +) +async def test_issues_tracker_maps_ticket_status_onto_the_board(druks_db, asked, board): + ticket = await _open_ticket() + + 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") + ticket = await _open_ticket() + 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_against_the_selected_repo(druks_db, monkeypatch): + await _connect_github() + _pin_software_factory_settings(monkeypatch, tracker="issues") + ticket = await _open_ticket(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 item.repo == "acme/widget" + assert started[0]["subject"].id == item.id + + +async def test_ready_for_agent_on_a_parked_build_moves_the_ticket_to_in_review( + druks_db, monkeypatch +): + await _connect_github() + _pin_software_factory_settings(monkeypatch, tracker="issues") + ticket = await _open_ticket(title="Add an endpoint") + item = await make_test_work_item( + repo="acme/widget", + source="issues", + ticket_key=ticket.identifier, + title=ticket.title, + ) + await seed_build_run(druks_db, work_item_id=item.id, state="parked", input_gate=ReviewWork.name) + started = [] + + async def fake_start(cls, **kwargs): + started.append(kwargs) + return "should-not-run" + + monkeypatch.setattr(Build, "start", classmethod(fake_start)) + + await ticket.transition(Status.READY_FOR_AGENT) + + assert started == [] + assert (await Ticket.get_for_identifier(ticket.identifier)).status == Status.IN_REVIEW diff --git a/backend/tests/software_factory/test_project_repo_routes.py b/backend/tests/software_factory/test_project_repo_routes.py index 9c998121..342e777a 100644 --- a/backend/tests/software_factory/test_project_repo_routes.py +++ b/backend/tests/software_factory/test_project_repo_routes.py @@ -39,6 +39,44 @@ async def test_get_project_returns_the_summary_or_404(client: TestClient): assert (await client.get("/api/software_factory/projects/999999")).status_code == 404 +async def test_create_and_update_carry_a_ticket_prefix(client: TestClient): + created = ( + await client.post("/api/software_factory/projects", json={"name": "Acme", "prefix": "acm"}) + ).json() + assert created["prefix"] == "ACM" + + updated = await client.patch( + f"/api/software_factory/projects/{created['id']}", json={"prefix": "acme"} + ) + assert updated.status_code == 200 + assert updated.json()["prefix"] == "ACME" + + refused = await client.patch( + f"/api/software_factory/projects/{created['id']}", json={"prefix": "1"} + ) + assert refused.status_code == 422 + + +async def test_a_taken_prefix_is_a_conflict(client: TestClient): + await client.post("/api/software_factory/projects", json={"name": "BOX", "prefix": "box"}) + acme = (await client.post("/api/software_factory/projects", json={"name": "Acme"})).json() + + refused = await client.patch( + f"/api/software_factory/projects/{acme['id']}", json={"prefix": "box"} + ) + assert refused.status_code == 409 + assert "already in use" in refused.json()["detail"] + assert (await client.get(f"/api/software_factory/projects/{acme['id']}")).json()[ + "prefix" + ] == "ACM" + + created = await client.post( + "/api/software_factory/projects", json={"name": "Other", "prefix": "BOX"} + ) + assert created.status_code == 409 + assert "already in use" in created.json()["detail"] + + async def test_adding_a_repo_dispatches_a_profile_run(client: TestClient, monkeypatch): calls = _stub_profile_dispatch(monkeypatch) diff --git a/backend/tests/software_factory/test_ticketing.py b/backend/tests/software_factory/test_ticketing.py index 5f17c24a..0353afbf 100644 --- a/backend/tests/software_factory/test_ticketing.py +++ b/backend/tests/software_factory/test_ticketing.py @@ -1,10 +1,17 @@ import json +from types import SimpleNamespace import httpx import pytest from conftest import connect_service -from druks.contrib.software_factory.app import SoftwareFactory, check_tracker_identity +from druks.apps.settings import field_choices, field_visibility, validate_field_choice_details +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 from druks.contrib.software_factory.ticketing.linear import Linear from druks.core import services @@ -207,6 +214,81 @@ 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 + + +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 4f99a03f..c71986c8 100644 --- a/backend/tests/test_mcp_endpoint.py +++ b/backend/tests/test_mcp_endpoint.py @@ -11,6 +11,8 @@ 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 Ticket +from druks.contrib.software_factory.models import Project, ProjectRepo from druks.core.apis.exceptions import UnknownTicketError from druks.durable.models import Artifact, Run from druks.mcp.exceptions import InvalidAgentToolError @@ -169,7 +171,15 @@ async def test_tools_list_pins_platform_and_app_tools(app, pat_token, mode): tools = {tool.name: tool for tool in await client.list_tools()} assert list(tools)[:7] == _TOOL_NAMES - assert list(tools)[7:] == ["software_factory_start", "software_factory_review"] + assert list(tools)[7:] == [ + "software_factory_create_ticket", + "software_factory_get_ticket", + "software_factory_update_ticket", + "software_factory_set_status", + "software_factory_add_comment", + "software_factory_start", + "software_factory_review", + ] expected_annotations = { "cancel_run": (False, True, True), @@ -217,6 +227,38 @@ async def test_tools_list_pins_platform_and_app_tools(app, pat_token, mode): assert not tools["get_usage"].input_schema.get("required") +async def test_issues_ticket_tools_read_and_comment_as_the_pat_account(app, account, pat_token): + project = await Project.create(name="Acme", prefix="WID") + repo = await ProjectRepo.create(project_id=project.id, full_name="acme/widget") + ticket = await Ticket.create( + repo_id=repo.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/backend/tests/test_ui_actions.py b/backend/tests/test_ui_actions.py index 8eef52c1..8a9ba433 100644 --- a/backend/tests/test_ui_actions.py +++ b/backend/tests/test_ui_actions.py @@ -9,9 +9,12 @@ Form, Link, MultiUploadField, + Option, Page, SecretField, Section, + SelectField, + TextAreaField, TextField, ) from druks.ui.fields import PageField @@ -73,9 +76,34 @@ def test_a_form_carries_its_fields_and_the_action_that_sends_them(): } ] assert block["action"]["operation"] == "write_note" + assert block["submit"] == "button" + assert block["layout"] == "stack" assert "presentation" not in block +def test_a_markdown_text_area_stays_source_on_the_wire(): + (block,) = wire( + Form( + action=Action(label="Save", operation="write_note"), + fields=[TextAreaField(name="body", label="Note", markdown=True, rows=8)], + ) + ) + + assert block["fields"] == [ + { + "field": "text_area", + "name": "body", + "label": "Note", + "value": "", + "placeholder": "", + "helpText": "", + "isRequired": False, + "rows": 8, + "markdown": True, + } + ] + + def test_an_action_can_collect_fields_before_it_runs(): (block,) = wire( Action( @@ -132,6 +160,30 @@ def test_multi_upload_fields_round_trip_through_the_page(owner: str): ] +def test_a_select_option_carries_its_group(): + (block,) = wire( + Form( + fields=[ + SelectField( + name="repo_id", + label="Repo", + options=[ + Option("acme/app", value="12", group="Acme"), + Option("beta/api", value="14", group="Beta"), + ], + is_required=True, + ) + ], + action=Action(label="Save", operation="write_note"), + ) + ) + + assert block["fields"][0]["options"] == [ + {"value": "12", "label": "acme/app", "group": "Acme"}, + {"value": "14", "label": "beta/api", "group": "Beta"}, + ] + + def test_a_multi_upload_field_starts_optional_without_a_value(): """The picker cannot send stored file ids back to the browser.""" field = MultiUploadField(name="photos", label="Photos") @@ -213,6 +265,15 @@ def test_a_form_keeps_all_fields_on_the_form(): ) +def test_a_form_that_submits_on_change_cannot_also_confirm(): + with pytest.raises(ValueError, match="submits on change"): + Form( + action=Action(label="Save", operation="write_note", confirm="Sure?"), + fields=[TextField(name="body", label="Note")], + submit="change", + ) + + def check(page: Page) -> None: for action in page.iter_actions(): action.check_operation("field_notes", OPERATIONS) diff --git a/backend/tests/test_ui_data_blocks.py b/backend/tests/test_ui_data_blocks.py index c960db09..ce83b7f7 100644 --- a/backend/tests/test_ui_data_blocks.py +++ b/backend/tests/test_ui_data_blocks.py @@ -23,6 +23,7 @@ TableColumn, TableRow, Text, + TextField, TextValue, TimeValue, ) @@ -86,8 +87,8 @@ def test_a_table_cell_can_reach_another_page(): ) assert block["columns"] == [ - {"label": "Peer", "align": "start"}, - {"label": "Answers", "align": "end"}, + {"label": "Peer", "align": "start", "width": ""}, + {"label": "Answers", "align": "end", "width": ""}, ] assert block["rows"][0]["cells"][0]["link"]["page"] == "peer" assert block["emptyText"] == "No peers yet." @@ -162,10 +163,50 @@ def test_cards_finds_an_action_in_a_card_and_in_its_empty_state(): block = Cards( cards=[Card(title="Peer 7", controls=[Action(label="Retire", operation="retire_peer")])], empty=EmptyState("No peer yet", controls=[Action(label="Scan", operation="scan")]), + drop=Action(label="Move", operation="move_peer"), ) - assert [action.operation for action in block.iter_actions()] == ["retire_peer", "scan"] + assert [action.operation for action in block.iter_actions()] == [ + "move_peer", + "retire_peer", + "scan", + ] + + +def test_cards_drop_cannot_collect_fields_or_confirm(): + with pytest.raises(ValueError, match="drop is the submit"): + Cards( + drop=Action( + label="Move", + operation="move_peer", + fields=[TextField(name="reason", label="Reason")], + ) + ) + with pytest.raises(ValueError, match="drop is the submit"): + Cards(drop=Action(label="Move", operation="move_peer", confirm="Move this peer?")) + + +def test_cards_carries_stack_layout_drop_and_card_drag(): + (block,) = wire( + Cards( + layout="stack", + drop=Action( + label="Move", + operation="move_peer", + arguments={"status": "todo"}, + ), + cards=[Card(title="peer-7", drag={"identifier": "P-7"})], + ) + ) + + assert block["layout"] == "stack" + assert block["drop"]["operation"] == "move_peer" + assert block["drop"]["arguments"] == {"status": "todo"} + assert block["cards"][0]["drag"] == {"identifier": "P-7"} def test_cards_with_none_and_nothing_to_say_carries_no_empty_state(): assert Cards().empty is None + assert Cards().layout == "wrap" + assert Cards().drop is None + assert Card().drag == {} diff --git a/backend/tests/test_ui_pages.py b/backend/tests/test_ui_pages.py index c078c22f..307fb685 100644 --- a/backend/tests/test_ui_pages.py +++ b/backend/tests/test_ui_pages.py @@ -139,11 +139,11 @@ def test_a_child_of_a_child_fails_at_declaration(): history.child("/deeper") -def test_a_missing_landing_page_fails(): +def test_a_missing_landing_page_is_allowed(): declare("no_landing", "/notes", "notes") - with pytest.raises(PageRouteError, match=r"declares 0 pages at '/'"): - list_pages_for_app("no_landing", "no_landing") + routes = list_pages_for_app("no_landing", "no_landing") + assert [page.name for page in routes] == ["notes"] def test_two_landing_pages_fail(): @@ -194,6 +194,19 @@ def test_an_extra_parameter_must_come_from_the_route(): list_pages_for_app("extra_parameter", "extra_parameter") +def test_an_optional_extra_parameter_is_a_query_filter(): + declare("query_filter", "/", "overview") + + async def notes(status: str = ""): + return Page(title="notes") + + notes.__module__ = "query_filter.pages" + page("/notes")(notes) + + names = [declaration.name for declaration in list_pages_for_app("query_filter", "query_filter")] + assert names == ["overview", "notes"] + + def test_navigation_resolves_page_labels(): app = load_app("field_notes") diff --git a/docs/concepts.md b/docs/concepts.md index 78bf025f..73e6a4a2 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -53,8 +53,9 @@ name must match `App.name`. The same name scopes: - Provider credentials and prerequisites that are specific to the domain - Optional static frontend assets in the app package. -The bundled `software_factory` app owns projects, work items, ticket intake, -GitHub branches, pull requests, coding-agent policy, and dashboard pages. These +The bundled `software_factory` app owns projects, work items, ticket intake +(Linear, Jira, or the local board), GitHub branches, pull requests, +coding-agent policy, and dashboard pages. These features are examples, not platform guarantees. ## Durability and recovery diff --git a/docs/configuration.md b/docs/configuration.md index efbf7cc9..b1e288f1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -137,7 +137,7 @@ caches, and the sandbox provisioning gate. | TOML key | Purpose | | --- | --- | -| `urls.endpoint` | Browser-visible dashboard base URL used to build MCP OAuth callbacks | +| `urls.endpoint` | Browser-visible dashboard base URL for MCP OAuth callbacks and sandbox access to this appliance's `/mcp` | | `urls.webhook_host` | Public webhook hostname used by `druks doctor` for its ingress probe | | `identity.mode` | `none` (default, no authentication, single operator), `header` (edge-asserted identity), or `jwt` (validated edge-signed assertion) | | `identity.header` | The trusted identity header. The shipped Caddy edge also uses it. Header and JWT modes have no default and require it | @@ -319,11 +319,40 @@ client at another compatible GitHub API endpoint. ## Ticketing integrations -Tracker credentials are service identities. Connect Linear or Jira Cloud from +Select the tracker in **Software Factory → Settings**. The default is Linear. +**none** leaves Software Factory without a ticket tracker. + +**Linear** and **Jira** are service identities. Connect them 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**. +and webhook secret. The Jira identity uses a base URL, email, API token, and +webhook secret. Druks validates the credentials before it stores them. Those +trackers show status-name knobs for the trigger status and the resting status. + +Select **druks** to use Software Factory's local issue board on this appliance. +The stored value is `issues`. That choice needs no credentials. Linear and Jira +status-name knobs stay hidden. The trigger status is Ready for Agent. It is not +a setting. `druks doctor` reports the tracker as healthy. + +The dashboard shows the board and ticket pages only for +**druks**. Each ticket picks a GitHub repository from a Software Factory +project. Set that project's ticket prefix (2–6 letters A–Z, or two letters and a +digit 1–9) on **Software Factory → Projects**. Creating a project fills the +first three letters of its name; change the field before save if you want +another. Druks mints identifiers as `{prefix}-{n}` once. +Changing the ticket's repository does not remint the identifier. A project +without a prefix cannot mint tickets. + +A ticket that enters Ready for Agent opens a build against the selected +repository. If a scheduled, running, or parked run already exists for that +ticket, Software Factory does not start another. + +Each local-board build ships this appliance's `/mcp` into the sandbox as the +`druks` server. The sandbox authenticates with a PAT for the run account. The +agent reads the ticket with `software_factory_get_ticket` and posts with +`software_factory_add_comment`. The Druks identifier is not a GitHub issue +number. Linear and Jira builds do not receive this MCP. Set `urls.endpoint` so +the VM can reach `/mcp`. `druks doctor` also checks that `/mcp` answers when the +tracker is **druks**. Webhook URLs remain `/_external/linear/events/` and `/_external/jira/events/`. The Jira webhook uses a Jira Automation @@ -331,8 +360,9 @@ Webhook URLs remain `/_external/linear/events/` and Select **Issue data (Jira format)** as its body. Druks accepts the REST issue JSON under `issue`. Put the shared token in the -`x-druks-webhook-token` header. `druks doctor` treats a disconnected tracker as -optional. It reports pending setup if the selected tracker lacks a connection. +`x-druks-webhook-token` header. `druks doctor` treats a disconnected Linear or +Jira identity as optional when that tracker is not selected. It reports pending +setup if the selected tracker is Linear or Jira and that identity is missing. ## Harnesses diff --git a/docs/druks-ui.md b/docs/druks-ui.md index 83fcc5ff..fa02b01f 100644 --- a/docs/druks-ui.md +++ b/docs/druks-ui.md @@ -27,7 +27,7 @@ The contract uses eight terms. Each one has one meaning. | `Page` | One screen. A page function returns it. | | `Block` | One piece of a page. Blocks nest. | | `Value` | One rendered datum inside a block. | -| `Field` | One named input that the shell collects before an action runs. | +| `Field` | One named input. An action collects it before it runs. A page filter collects it in the URL query. | | `Action` | A control that calls one of the app's operations. | | `Link` | A control that navigates. | | `operation` | The `operation_id` of an app route. | @@ -137,9 +137,10 @@ cause. - A child declaration can live in another module. - A child inherits every parameter of its parent route. - An extra child parameter must come from the relative child path. -- A page function takes one parameter for each parameter of its route, and no - others. Each one must be callable by name, so a positional-only or variadic - parameter is a boot error. +- A page function takes one required parameter for each parameter of its route. + Extra parameters must have defaults. FastAPI binds them from the query string + as filters. Each parameter must be callable by name, so a positional-only or + variadic parameter is a boot error. - A catch-all is the last segment of its route. A catch-all anywhere else would swallow every route under it, so it is a boot error. - A static child is a tab. The parent is the first tab. @@ -174,6 +175,31 @@ placeholder from the `Link` `arguments` and percent-encodes the value. `arguments` values are strings; FastAPI coerces each one to the type the page declares. A `Link` missing an argument reads as broken. +A page function can take extra parameters with defaults. Those are query +filters. The shell keeps their values in the URL and sends them on every page +read. `run` and `parkedAt` stay the shell's: they name a parked decision, not a +filter. + +```python +@ui.page("/peers") +async def peers(status: str = ""): + return ui.Page( + "Peers", + filters=[ + ui.SelectField( + name="status", + label="Status", + options=[ + ui.Option("Any", value=""), + ui.Option("Live", value="live"), + ], + value=status, + ) + ], + blocks=[...], + ) +``` + The parent of a page is its `parent` entry when it has one. Otherwise it is the declared page whose `path` is the longest proper prefix of this page's `path`, and the landing page when no other page is a prefix. A parameterized detail @@ -683,6 +709,8 @@ class Card: description: str = "" blocks: list[Block] = [] controls: list[Action | Link] = [] + link: Link | None = None + drag: dict = {} ``` ```json @@ -691,10 +719,20 @@ class Card: "title": "peer-7", "description": "Last answered 4 minutes ago.", "blocks": [{"block": "text", "text": "Healthy."}], - "controls": [{"block": "link", "label": "Open", "page": "peer", "arguments": {"peer_id": "7"}, "url": ""}] + "controls": [], + "link": {"block": "link", "label": "peer-7", "page": "peer", "arguments": {"peer_id": "7"}, "url": ""}, + "drag": {} } ``` +`link` is the card's destination. With no `controls`, the shell makes the whole +panel the control. With `controls`, the title carries the link so a button is +not nested inside an anchor. A linked card should not hold other links in +`blocks`. + +`drag` is what a [`Cards.drop`](#cards) action receives. Empty means the card +does not move. + ### Cards ```python @@ -703,14 +741,18 @@ class Cards: title: str = "" cards: list[Card] = [] empty: EmptyState | None = None + layout: Literal["wrap", "stack"] = "wrap" + drop: Action | None = None ``` ```json { "block": "cards", "title": "Peers", - "cards": [{"block": "card", "title": "peer-7", "description": "", "blocks": [], "controls": []}], - "empty": null + "cards": [{"block": "card", "title": "peer-7", "description": "", "blocks": [], "controls": [], "drag": {}}], + "empty": null, + "layout": "wrap", + "drop": null } ``` @@ -719,16 +761,31 @@ One card for each of a set of things. ```python ui.Cards( title="Peers", - cards=[ui.Card(title=peer.name, blocks=[...], controls=[...]) for peer in peers], + cards=[ + ui.Card( + title=peer.name, + blocks=[...], + link=ui.Link(peer.name, page="peer", arguments={"peer_id": str(peer.id)}), + ) + for peer in peers + ], empty=ui.EmptyState("No peer yet", controls=[ui.Link("Add one", page="new_peer")]), ) ``` -The shell arranges the cards. It fits as many across as the screen takes, so -`Cards` sets no geometry of its own. +`wrap` (the default) fits as many cards across as the screen takes. `stack` +is one column, for a board of statuses. + +`drop` is the action a dragged card submits onto this list. The shell merges +the card's `drag` into the action arguments and runs the operation. The drop +is the submit: `drop` cannot set `fields` or `confirm`. A drop onto the same +list does nothing. Clicking a linked card still opens it. While a card is +dragged, the shell dims it and shows a placeholder in the list under the +pointer. The action runs only on drop. With no cards, the shell shows the title and `empty` in their place. With no -cards and no `empty`, it shows nothing. `Table` reads the same way. +cards and no `empty`, it shows nothing unless `drop` is set, so an empty +column can still receive a card. `Table` reads the same way for `empty`. `empty` takes an `EmptyState`, not a line of text, because an empty page usually has to say what to do next. @@ -849,6 +906,8 @@ class Form: description: str = "" fields: list[Field] = [] action: Action + submit: Literal["button", "change"] = "button" + layout: Literal["stack", "prose", "row"] = "stack" ``` ```python @@ -891,10 +950,16 @@ Druks refuses the form when the page function builds it. "confirm": "", "refresh": "page", "link": null - } + }, + "submit": "button", + "layout": "stack" } ``` +`submit="change"` sends the form when a select changes or a text field blurs. +The shell draws no button. `layout="prose"` is a title and body. `layout="row"` +is a labelled property. A form cannot both submit on change and set `confirm`. + ### Timeline ```python @@ -1167,6 +1232,7 @@ class Facts: class TableColumn: label: str align: Literal["start", "end"] = "start" + width: str = "" class TableRow: @@ -1186,7 +1252,7 @@ class Table: { "block": "table", "title": "Peers", - "columns": [{"label": "Peer", "align": "start"}, {"label": "Answers", "align": "end"}], + "columns": [{"label": "Peer", "align": "start", "width": ""}, {"label": "Answers", "align": "end", "width": ""}], "rows": [ { "cells": [ @@ -1199,10 +1265,12 @@ class Table: } ``` -Every row must have one cell for each column. With no rows the shell shows -`empty_text`, and nothing of its own. A wide table scrolls inside its own -container, on a narrow screen as well: a stacked row would lose the header each -cell belongs to. +Every row must have one cell for each column. With no rows the shell still +draws the columns and shows `empty_text` in the body. `width` on a column is +a CSS size that column keeps in every table that names it; empty shares the +leftover. A cell that overruns its column stays on one line with an ellipsis. +A wide table scrolls inside its own container, on a narrow screen as +well: a stacked row would lose the header each cell belongs to. A row's `detail` is the sentence it has no room for — the failure behind a status, the reason behind a verdict. The shell keeps it folded and the reader @@ -1244,15 +1312,16 @@ class Stack: ```python class Columns: block: Literal["columns"] = "columns" + layout: Literal["even", "sidebar"] = "even" blocks: list[Block] = [] ``` ```json -{"block": "columns", "blocks": []} +{"block": "columns", "layout": "even", "blocks": []} ``` -Each child block is one column. The columns share the width. On a narrow -screen they stack. +Each child block is one column. `even` shares the width. `sidebar` keeps the +last column a rail. On a narrow screen they stack. `Stack` and `Columns` hold every V1 block, including each other. They have no special cases. @@ -1372,12 +1441,18 @@ class TextAreaField: help_text: str = "" is_required: bool = False rows: int = 4 + markdown: bool = False ``` ```json -{"field": "text_area", "name": "body", "label": "Note", "value": "", "placeholder": "", "helpText": "", "isRequired": false, "rows": 4} +{"field": "text_area", "name": "body", "label": "Note", "value": "", "placeholder": "", "helpText": "", "isRequired": false, "rows": 4, "markdown": false} ``` +`markdown=True` keeps the value as markdown source. The shell renders formatted +text in place. A selection toolbar applies marks, headings, lists, and links. +Submit still sends markdown. There is no HTML roundtrip. Read-only `Markdown` +blocks still use the GFM renderer. + ### NumberField ```python @@ -1403,6 +1478,7 @@ class NumberField: class Option: value: str label: str + group: str = "" class SelectField: @@ -1415,18 +1491,39 @@ class SelectField: is_required: bool = False ``` +```python +ui.SelectField( + name="repo_id", + label="Repo", + options=[ + ui.Option("acme/app", value="12", group="Acme"), + ui.Option("acme/docs", value="13", group="Acme"), + ui.Option("beta/api", value="14", group="Beta"), + ], + value="12", + is_required=True, +) +``` + ```json { "field": "select", "name": "severity", "label": "Severity", - "options": [{"value": "low", "label": "Low"}, {"value": "high", "label": "High"}], + "options": [{"value": "low", "label": "Low", "group": ""}, {"value": "high", "label": "High", "group": ""}], "value": "low", "helpText": "", "isRequired": true } ``` +A non-empty `group` nests the option in an `` of that name. +Consecutive options that share a group share one group. An empty `group` is a +flat choice. Radio and multi-select ignore `group`. A required select whose +`value` is not among the options starts on the first option. The browser already +paints that choice; submitting the empty declared value would send a blank to +the operation. + ### MultiSelectField ```python @@ -1445,7 +1542,7 @@ class MultiSelectField: "field": "multi_select", "name": "tags", "label": "Tags", - "options": [{"value": "rack", "label": "Rack"}], + "options": [{"value": "rack", "label": "Rack", "group": ""}], "value": ["rack"], "helpText": "", "isRequired": false @@ -1470,7 +1567,7 @@ class RadioField: "field": "radio", "name": "decision", "label": "Decision", - "options": [{"value": "approve", "label": "Approve"}], + "options": [{"value": "approve", "label": "Approve", "group": ""}], "value": "", "helpText": "", "isRequired": true @@ -1628,6 +1725,7 @@ class Page: title: str description: str = "" controls: list[Action | Link] = [] + filters: list[Field] = [] blocks: list[Block] = [] follows: Follows | None = None ``` @@ -1637,6 +1735,7 @@ class Page: "title": "peer-7", "description": "One peer and its last sweep.", "controls": [], + "filters": [], "blocks": [{"block": "text", "text": "Healthy."}], "follows": {"subjectType": "peers", "subjectId": "7"} } @@ -1645,10 +1744,15 @@ class Page: A page's controls belong to that page. The shell chooses where to show them. An action in `blocks` stays with the body content. +`filters` are fields the shell renders in the page chrome. Changing one updates +the URL query and rereads the page. Empty `value` means any. They are not +actions: they do not call an operation. + `Page`, `Section`, `Card` and `EmptyState` all take `controls` the same way: a list of `Action` and `Link`, in the order the app wants them read. An `Action` calls one of the app's operations; a `Link` navigates. Both are things an -operator presses, so they share the row. +operator presses, so they share the row. A `Card` can also take `link`. That is +the card's destination, not a control on the row. ```python return ui.Page( diff --git a/docs/full-local.md b/docs/full-local.md index 32d50091..e835f4ec 100644 --- a/docs/full-local.md +++ b/docs/full-local.md @@ -153,6 +153,12 @@ workflow and its trigger. In the bundled distribution, `software_factory` is the reference app. Register a project in its dashboard. Use its configured ticket or GitHub trigger. +To run without Linear or Jira, select **druks** in +**Software Factory → Settings** and use the local board. GitHub remains +required for pull requests. Set a ticket prefix on the project before you mint +tickets. See +[ticketing integrations](configuration.md#ticketing-integrations). + The run appears on the subject page and in the Events feed. Agent-call pages stream transcript and artifact data. @@ -185,8 +191,10 @@ value. ## Webhook caveat GitHub, Linear, and Jira cannot connect to a loopback listener. Dashboard-initiated -actions work locally, but provider-driven flows need an HTTPS tunnel forwarding -to `127.0.0.1:8001`. Connect tracker credentials under **Settings → Connections → Services** and +actions work locally, including Software Factory's **druks** board, which needs +no tracker credentials. Provider-driven Linear and Jira flows need an HTTPS +tunnel forwarding to `127.0.0.1:8001`. Connect Linear or Jira under +**Settings → Connections → Services** when you use those trackers, and keep the exact public paths: ```text diff --git a/docs/quickstart.md b/docs/quickstart.md index 81a02336..11bcb7f3 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -78,7 +78,9 @@ To use it: 4. Open **Software Factory → Projects**. 5. Create a project and add the repository. 6. Profile the repository. Then use the configured ticket or GitHub trigger to - start work. + start work. For a tracker that needs no credentials, select **druks** in + **Software Factory → Settings**. See + [ticketing integrations](configuration.md#ticketing-integrations). 7. Watch the work item, event feed, agent calls, and each parked gate in the dashboard. diff --git a/docs/writing-an-app.md b/docs/writing-an-app.md index bc6d764a..6c7c3fc0 100644 --- a/docs/writing-an-app.md +++ b/docs/writing-an-app.md @@ -10,6 +10,10 @@ Druks supplies durable execution and shared operating services. Read [the app boundary](concepts.md#the-app-boundary) before you assign ownership of a capability. +The bundled `software_factory` app owns its local issue board and the GitHub PR +funnel. That board is not a second app, not an author-surface namespace, and +not a `Service` kind. + ## Scaffold and prove the package ```bash diff --git a/frontend/README.md b/frontend/README.md index ee6a68f9..37125ea9 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -29,8 +29,10 @@ runs lint, tests, and build for PRs into `main` and `codex/` stack branches. - Shared routing and fallback behavior. Bundled app UI lives under `src/apps//`. Its module calls -`registerAppUI()` with routes and an optional home path. The backend app class -declares the subnav tabs. The roster supplies these tabs to the frontend. +`registerAppUI()` with routes and an optional home path. A Python-page app +declares subnav tabs on its backend class; the roster supplies them. An app +that ships its own JavaScript sets `navigation` on the registration, or +`navigationFor` when the tabs depend on settings. Import the module one time from `src/apps/index.ts`. The shell finds the registration and does not hardcode the app name. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 04210cef..0ca93949 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,12 @@ "dependencies": { "@novnc/novnc": "1.7.0", "@tanstack/react-query": "^5.102.8", + "@tiptap/extension-bubble-menu": "^3.31.3", + "@tiptap/extension-placeholder": "^3.31.3", + "@tiptap/markdown": "^3.31.3", + "@tiptap/pm": "^3.31.3", + "@tiptap/react": "^3.31.3", + "@tiptap/starter-kit": "^3.31.3", "lucide-react": "^1.39.0", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -650,6 +656,31 @@ } } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1226,6 +1257,465 @@ } } }, + "node_modules/@tiptap/core": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.3.tgz", + "integrity": "sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.3.tgz", + "integrity": "sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.3.tgz", + "integrity": "sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.3.tgz", + "integrity": "sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.3.tgz", + "integrity": "sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.3.tgz", + "integrity": "sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.3.tgz", + "integrity": "sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz", + "integrity": "sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.3.tgz", + "integrity": "sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.3" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.3.tgz", + "integrity": "sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.3.tgz", + "integrity": "sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.3" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.3.tgz", + "integrity": "sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.3.tgz", + "integrity": "sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.3.tgz", + "integrity": "sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.3.tgz", + "integrity": "sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.3.tgz", + "integrity": "sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.3.tgz", + "integrity": "sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.3.tgz", + "integrity": "sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.3.tgz", + "integrity": "sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.3.tgz", + "integrity": "sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.3.tgz", + "integrity": "sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.31.3.tgz", + "integrity": "sha512-9jYtR8ELEw7GVaruyrm4oFkPcjig9Q+crc+dpmarhBNXUmxagCdlhVzNwCJ2WJRzvBAtx59sEYqNTU38Wx8S3A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.3" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz", + "integrity": "sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz", + "integrity": "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.3.tgz", + "integrity": "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.3.tgz", + "integrity": "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/markdown": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.31.3.tgz", + "integrity": "sha512-rBbYSxasseUoaBo15C8Yoaqafo7mJJzxwAwV9Z1OpLBvOroW9nQ0EbOdqaSYRw3/d9pP71B/3LNSzKyfyy+dpQ==", + "license": "MIT", + "dependencies": { + "marked": "^17.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.3.tgz", + "integrity": "sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.4.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.11", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.42.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.3.tgz", + "integrity": "sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.31.3", + "@tiptap/extension-floating-menu": "^3.31.3" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.3.tgz", + "integrity": "sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/extension-blockquote": "3.31.3", + "@tiptap/extension-bold": "3.31.3", + "@tiptap/extension-bullet-list": "3.31.3", + "@tiptap/extension-code": "3.31.3", + "@tiptap/extension-code-block": "3.31.3", + "@tiptap/extension-document": "3.31.3", + "@tiptap/extension-dropcursor": "3.31.3", + "@tiptap/extension-gapcursor": "3.31.3", + "@tiptap/extension-hard-break": "3.31.3", + "@tiptap/extension-heading": "3.31.3", + "@tiptap/extension-horizontal-rule": "3.31.3", + "@tiptap/extension-italic": "3.31.3", + "@tiptap/extension-link": "3.31.3", + "@tiptap/extension-list": "3.31.3", + "@tiptap/extension-list-item": "3.31.3", + "@tiptap/extension-list-keymap": "3.31.3", + "@tiptap/extension-ordered-list": "3.31.3", + "@tiptap/extension-paragraph": "3.31.3", + "@tiptap/extension-strike": "3.31.3", + "@tiptap/extension-text": "3.31.3", + "@tiptap/extension-underline": "3.31.3", + "@tiptap/extensions": "3.31.3", + "@tiptap/pm": "3.31.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -1336,7 +1826,6 @@ "version": "19.2.5", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -1348,6 +1837,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.69.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", @@ -2512,6 +3007,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.2.tgz", + "integrity": "sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3391,6 +3895,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3507,6 +4017,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz", + "integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -4473,6 +4995,12 @@ "node": ">= 0.8.0" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4682,6 +5210,145 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz", + "integrity": "sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz", + "integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.1.tgz", + "integrity": "sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.3", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz", + "integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4866,6 +5533,12 @@ "@rolldown/binding-win32-x64-msvc": "1.2.6" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -5555,6 +6228,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 927dd9e3..f1c10273 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,12 @@ "dependencies": { "@novnc/novnc": "1.7.0", "@tanstack/react-query": "^5.102.8", + "@tiptap/extension-bubble-menu": "^3.31.3", + "@tiptap/extension-placeholder": "^3.31.3", + "@tiptap/markdown": "^3.31.3", + "@tiptap/pm": "^3.31.3", + "@tiptap/react": "^3.31.3", + "@tiptap/starter-kit": "^3.31.3", "lucide-react": "^1.39.0", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 72b0ded4..43c73ddd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -263,7 +263,9 @@ function AppShell({ }, [location, app, ui, navigate, defaultApp, hidden]) const declaredNavigation = - ui?.navigation ?? rosterQuery.data?.find((entry) => entry.name === app)?.navigation + ui?.navigationFor?.(settingsQuery.data) ?? + ui?.navigation ?? + rosterQuery.data?.find((entry) => entry.name === app)?.navigation const appSettingsPath = urlApp && settingsQuery.data?.apps.some((entry) => entry.name === urlApp) ? `/apps/${urlApp}/settings` diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d0761b43..d36396ad 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -232,7 +232,8 @@ export const api = { listApps: () => getJSON('/api/apps'), // ``path`` is the location under the app's own root: "" for the landing // page, "/notes/7" for a detail page. - readPage: (app: string, path: string) => getJSON(`/api/${app}/pages${path}`), + readPage: (app: string, path: string, search?: string) => + getJSON(`/api/${app}/pages${path}${search ? `?${search}` : ''}`), // A parked run's gate. The answer echoes ``parkedAt`` unchanged, so it names // the exact question it answers; a run that re-parked rejects the stale one. getGate: (run: string) => getJSON(`/api/gates/${run}`), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index da550ee0..df0c6742 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -304,6 +304,7 @@ export interface Fact { export interface TableColumn { label: string align: 'start' | 'end' + width?: string } export interface TableRow { @@ -317,6 +318,8 @@ export interface CardBlock { description: string blocks: Block[] controls: (Action | Link)[] + link?: Link | null + drag?: Record } export interface EmptyStateBlock { @@ -344,6 +347,7 @@ export interface FileSummary { export interface Option { value: string label: string + group?: string } interface FieldBase { @@ -353,10 +357,17 @@ interface FieldBase { isRequired: boolean } -// One named input that the shell collects before an action runs. +// One named input. An action collects it before it runs. A page filter +// collects it in the URL query. export type Field = | (FieldBase & { field: 'text'; value: string; placeholder: string }) - | (FieldBase & { field: 'text_area'; value: string; placeholder: string; rows: number }) + | (FieldBase & { + field: 'text_area' + value: string + placeholder: string + rows: number + markdown?: boolean + }) | (FieldBase & { field: 'number' value: number | null @@ -437,7 +448,7 @@ export type Block = } | { block: 'list'; title: string; items: Value[] } | { block: 'stack'; gap: 'small' | 'medium' | 'large'; blocks: Block[] } - | { block: 'columns'; blocks: Block[] } + | { block: 'columns'; layout?: 'even' | 'sidebar'; blocks: Block[] } | Action | { block: 'form' @@ -445,9 +456,18 @@ export type Block = description: string fields: Field[] action: Action + submit?: 'button' | 'change' + layout?: 'stack' | 'prose' | 'row' } | CardBlock - | { block: 'cards'; title: string; cards: CardBlock[]; empty: EmptyStateBlock | null } + | { + block: 'cards' + title: string + cards: CardBlock[] + empty: EmptyStateBlock | null + layout?: 'wrap' | 'stack' + drop?: Action | null + } | { block: 'callout' tone: 'info' | 'success' | 'warning' | 'danger' @@ -471,6 +491,7 @@ export interface PageSnapshot { title: string description: string controls: (Action | Link)[] + filters?: Field[] blocks: Block[] follows: Follows | null } diff --git a/frontend/src/apps/registry.tsx b/frontend/src/apps/registry.tsx index a665ec20..8b8d489d 100644 --- a/frontend/src/apps/registry.tsx +++ b/frontend/src/apps/registry.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from 'react' +import type { AppsSettingsResponse } from '../api/types' + export interface AppRoute { /** A wouter pattern under the router base, such as /notes/:id. */ path: string @@ -15,6 +17,9 @@ export interface AppUI { // JavaScript. A Python-page app leaves this off and declares // ``App.navigation`` on its backend class instead. navigation?: [string, string][] + // When set, the shell asks this for the tabs so an app can hide pages that + // only apply under a given setting. + navigationFor?: (settings?: AppsSettingsResponse) => [string, string][] // Where a feed row about one of this app's subjects navigates. The shell knows // an app has subjects, never where its pages put them. subjectPath?: (subject: { type: string; id: string }, target?: SubjectTarget) => string | undefined diff --git a/frontend/src/apps/software_factory/DruksIssuesPage.tsx b/frontend/src/apps/software_factory/DruksIssuesPage.tsx new file mode 100644 index 00000000..5b4356d6 --- /dev/null +++ b/frontend/src/apps/software_factory/DruksIssuesPage.tsx @@ -0,0 +1,11 @@ +import { AppPage } from '../../druksui/AppPage' +import { SOFTWARE_FACTORY } from './api' +import { NotFound } from './NotFound' +import { useDruksIssuesTracker } from './tracker' + +export function DruksIssuesPage({ page }: { page: string }) { + const isDruksIssues = useDruksIssuesTracker() + if (isDruksIssues === undefined) return null + if (isDruksIssues) return + return +} 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 diff --git a/frontend/src/apps/software_factory/projects/ProjectsPage.test.tsx b/frontend/src/apps/software_factory/projects/ProjectsPage.test.tsx index 6be0e518..a0c6c4fb 100644 --- a/frontend/src/apps/software_factory/projects/ProjectsPage.test.tsx +++ b/frontend/src/apps/software_factory/projects/ProjectsPage.test.tsx @@ -2,6 +2,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { api } from '../../../api/client' +import type { AppsSettingsResponse } from '../../../api/types' +import { SOFTWARE_FACTORY } from '../api' import { projectsApi } from './api' import { ProjectsPage } from './ProjectsPage' import type { Project, ProjectsResponse } from './types' @@ -14,18 +17,25 @@ vi.mock('./api', () => ({ repoBoard: vi.fn(), delete: vi.fn(), create: vi.fn(), + update: vi.fn(), }, })) +vi.mock('../../../api/client', () => ({ + api: { getAppSettings: vi.fn() }, +})) + const listMock = vi.mocked(projectsApi.list) const repoBoardMock = vi.mocked(projectsApi.repoBoard) const deleteMock = vi.mocked(projectsApi.delete) const createMock = vi.mocked(projectsApi.create) +const updateMock = vi.mocked(projectsApi.update) function project(overrides: Partial = {}): Project { return { id: 7, name: 'Target', + prefix: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', repos: [], @@ -33,12 +43,27 @@ function project(overrides: Partial = {}): Project { } } -function renderPage(projects: Project[]) { +function settingsWithTracker(tracker: string): AppsSettingsResponse { + return { + allowedEfforts: [], + apps: [ + { + name: SOFTWARE_FACTORY, + settings: [{ name: 'tracker', value: tracker }], + }, + ], + } as unknown as AppsSettingsResponse +} + +function renderPage(projects: Project[], tracker = 'issues') { listMock.mockResolvedValue({ projects } satisfies ProjectsResponse) repoBoardMock.mockResolvedValue({ rows: [] }) + const settings = settingsWithTracker(tracker) + vi.mocked(api.getAppSettings).mockResolvedValue(settings) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) + queryClient.setQueryData(['appSettings'], settings) return render( @@ -91,6 +116,7 @@ describe('ProjectsPage create row', () => { fireEvent.change(await screen.findByPlaceholderText(/new project name/), { target: { value: 'Acme' }, }) + expect((screen.getByPlaceholderText(/prefix/) as HTMLInputElement).value).toBe('ACM') const create = screen.getByText('+ create').closest('button')! await waitFor(() => expect(create.disabled).toBe(false)) fireEvent.click(create) @@ -98,7 +124,84 @@ describe('ProjectsPage create row', () => { // react-query hands the mutationFn a context argument too; the payload is // the first one. await waitFor(() => expect(createMock).toHaveBeenCalled()) - expect(createMock.mock.calls[0]![0]).toEqual({ name: 'Acme' }) + expect(createMock.mock.calls[0]![0]).toEqual({ name: 'Acme', prefix: 'ACM' }) + }) + + it('does not overwrite a prefix the operator edited', async () => { + renderPage([project()]) + + fireEvent.change(await screen.findByPlaceholderText(/new project name/), { + target: { value: 'Acme' }, + }) + fireEvent.change(screen.getByPlaceholderText(/prefix/), { + target: { value: 'box' }, + }) + fireEvent.change(screen.getByPlaceholderText(/new project name/), { + target: { value: 'Acme Tools' }, + }) + expect((screen.getByPlaceholderText(/prefix/) as HTMLInputElement).value).toBe('box') + }) + + it('sends the prefix when one is typed', async () => { + createMock.mockResolvedValue(project({ name: 'Acme', prefix: 'ACM' })) + renderPage([project()]) + + fireEvent.change(await screen.findByPlaceholderText(/new project name/), { + target: { value: 'Acme' }, + }) + fireEvent.change(screen.getByPlaceholderText(/prefix/), { + target: { value: 'acm' }, + }) + fireEvent.click(screen.getByText('+ create').closest('button')!) + + await waitFor(() => expect(createMock).toHaveBeenCalled()) + expect(createMock.mock.calls[0]![0]).toEqual({ name: 'Acme', prefix: 'acm' }) + }) +}) + +describe('ProjectsPage prefix note', () => { + it('notes that a project without a prefix cannot be selected on the board', async () => { + renderPage([project({ name: 'Bare' })]) + + expect( + await screen.findByText(/cannot be selected on the board/), + ).toBeTruthy() + }) + + it('does not note the board when the project has a prefix', async () => { + renderPage([project({ name: 'Acme', prefix: 'ACM' })]) + + expect(await screen.findByText('Acme')).toBeTruthy() + expect(screen.queryByText(/cannot be selected on the board/)).toBeNull() + }) + + it('hides the prefix field and note when the tracker is not druks', async () => { + renderPage([project({ name: 'Bare' })], 'linear') + + expect(await screen.findByText('Bare')).toBeTruthy() + expect(screen.queryByPlaceholderText(/prefix/)).toBeNull() + expect(screen.queryByText(/cannot be selected on the board/)).toBeNull() + expect(screen.queryByText('prefix')).toBeNull() + }) + + it('keeps the prefix editor open and names a taken prefix', async () => { + updateMock.mockRejectedValue( + new Error("project prefix 'BOX' is already in use. Pick a different one."), + ) + renderPage([ + project({ id: 2, name: 'Acme', prefix: null }), + project({ id: 1, name: 'BOX', prefix: 'BOX' }), + ]) + + fireEvent.click(await screen.findByText('prefix')) + const input = screen.getByPlaceholderText('DRU') + fireEvent.change(input, { target: { value: 'BOX' } }) + fireEvent.blur(input) + + await waitFor(() => expect(updateMock).toHaveBeenCalledWith(2, { prefix: 'BOX' })) + const alert = await screen.findByRole('alert') + expect(alert.textContent).toContain('already in use') + expect(screen.getByPlaceholderText('DRU')).toBeTruthy() }) }) diff --git a/frontend/src/apps/software_factory/projects/ProjectsPage.tsx b/frontend/src/apps/software_factory/projects/ProjectsPage.tsx index f2653292..1b63d1a4 100644 --- a/frontend/src/apps/software_factory/projects/ProjectsPage.tsx +++ b/frontend/src/apps/software_factory/projects/ProjectsPage.tsx @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { useFlashNote } from '../../../lib/useFlashNote' +import { useDruksIssuesTracker } from '../tracker' import { projectsApi } from './api' import { repoProfiling, useRepoRuns, type RepoProfiling } from './profiling' import type { Project, ProjectRepo } from './types' @@ -13,6 +14,13 @@ function pickPlaceholder(loading: boolean, available: number): string { return '— pick a repo —' } +function suggestedPrefix(name: string): string { + const letters = name.toUpperCase().replace(/[^A-Z]/g, '') + if (letters.length >= 3) return letters.slice(0, 3) + if (letters.length === 2) return `${letters}1` + return '' +} + function splitRepo(full: string): { org: string; short: string } { const i = full.indexOf('/') if (i < 0) return { org: '', short: full } @@ -21,6 +29,7 @@ function splitRepo(full: string): { org: string; short: string } { export function ProjectsPage() { const queryClient = useQueryClient() + const isDruksIssues = useDruksIssuesTracker() === true const runs = useRepoRuns() const anyProfiling = [...runs.values()].some((status) => status.state === 'running') const { data, isLoading, isError } = useQuery({ @@ -31,20 +40,35 @@ export function ProjectsPage() { refetchInterval: anyProfiling ? 3_000 : 30_000, }) - const [draft, setDraft] = useState('') + const [draftName, setDraftName] = useState('') + const [draftPrefix, setDraftPrefix] = useState('') + const [prefixDirty, setPrefixDirty] = useState(false) // A project delete can still fail (a race, a server error); surface it here at // page level as a transient error toast so it's never a silent no-op. const [deleteError, setDeleteError] = useFlashNote() const createMutation = useMutation({ mutationFn: projectsApi.create, onSuccess: () => { - setDraft('') + setDraftName('') + setDraftPrefix('') + setPrefixDirty(false) void queryClient.invalidateQueries({ queryKey: ['projects'] }) }, }) const onCreate = () => { - const name = draft.trim() - if (name) createMutation.mutate({ name }) + const name = draftName.trim() + if (name) { + const prefix = isDruksIssues ? draftPrefix.trim() : '' + createMutation.mutate(prefix ? { name, prefix } : { name }) + } + } + const onNameChange = (value: string) => { + setDraftName(value) + if (isDruksIssues && !prefixDirty) setDraftPrefix(suggestedPrefix(value)) + } + const onPrefixChange = (value: string) => { + setPrefixDirty(true) + setDraftPrefix(value) } if (isLoading) { @@ -83,8 +107,11 @@ export function ProjectsPage() { action={
@@ -95,14 +122,22 @@ export function ProjectsPage() { ) : (
{data.projects.map((p) => ( - + ))}
)} @@ -112,27 +147,44 @@ export function ProjectsPage() { } function CreateRow({ - value, - onChange, + name, + prefix, + showPrefix, + onNameChange, + onPrefixChange, onCreate, pending, }: { - value: string - onChange: (v: string) => void + name: string + prefix: string + showPrefix: boolean + onNameChange: (v: string) => void + onPrefixChange: (v: string) => void onCreate: () => void pending: boolean }) { - const enabled = value.trim().length > 0 && !pending + const enabled = name.trim().length > 0 && !pending return (
onChange(e.target.value)} + value={name} + onChange={(e) => onNameChange(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && enabled) onCreate() }} /> + {showPrefix && ( + onPrefixChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && enabled) onCreate() + }} + /> + )} @@ -142,9 +194,11 @@ function CreateRow({ function ProjectCard({ project, + showPrefix, onDeleteError, }: { project: Project + showPrefix: boolean onDeleteError: (message: string) => void }) { const queryClient = useQueryClient() @@ -154,6 +208,8 @@ function ProjectCard({ const [adding, setAdding] = useState(false) const [editingName, setEditingName] = useState(false) const [name, setName] = useState(project.name) + const [editingPrefix, setEditingPrefix] = useState(false) + const [prefix, setPrefix] = useState(project.prefix ?? '') const rename = useMutation({ mutationFn: (next: string) => projectsApi.update(project.id, { name: next }), @@ -162,6 +218,13 @@ function ProjectCard({ invalidate() }, }) + const savePrefix = useMutation({ + mutationFn: (next: string) => projectsApi.update(project.id, { prefix: next }), + onSuccess: () => { + setEditingPrefix(false) + invalidate() + }, + }) const remove = useMutation({ mutationFn: () => projectsApi.delete(project.id), onSuccess: invalidate, @@ -217,6 +280,40 @@ function ProjectCard({ )} {repoCount} + {showPrefix && + (editingPrefix ? ( + setPrefix(e.target.value)} + onBlur={() => { + const next = prefix.trim().toUpperCase() + if (next !== (project.prefix ?? '')) savePrefix.mutate(next) + else { + setPrefix(project.prefix ?? '') + setEditingPrefix(false) + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter') (e.target as HTMLInputElement).blur() + if (e.key === 'Escape') { + setPrefix(project.prefix ?? '') + setEditingPrefix(false) + savePrefix.reset() + } + }} + /> + ) : ( + setEditingPrefix(true)} + title="click to set ticket prefix" + > + {project.prefix ?? 'prefix'} + + ))}
+ {savePrefix.error ? ( +

+ {savePrefix.error instanceof Error + ? savePrefix.error.message + : String(savePrefix.error)} +

+ ) : ( + showPrefix && + !project.prefix && ( +

+ Without a prefix, this project's repos cannot be selected on the board. +

+ ) + )} {!collapsed && ( diff --git a/frontend/src/apps/software_factory/projects/types.ts b/frontend/src/apps/software_factory/projects/types.ts index 337e770f..c044d002 100644 --- a/frontend/src/apps/software_factory/projects/types.ts +++ b/frontend/src/apps/software_factory/projects/types.ts @@ -29,6 +29,7 @@ export interface ProjectRepo extends SubjectSummary { export interface Project { id: number name: string + prefix: string | null createdAt: string updatedAt: string repos: ProjectRepo[] @@ -40,10 +41,12 @@ export interface ProjectsResponse { export interface CreateProjectRequest { name: string + prefix?: string | null } export interface UpdateProjectRequest { name?: string | null + prefix?: string | null } export interface AddProjectRepoRequest { diff --git a/frontend/src/apps/software_factory/tracker.test.ts b/frontend/src/apps/software_factory/tracker.test.ts new file mode 100644 index 00000000..379cf905 --- /dev/null +++ b/frontend/src/apps/software_factory/tracker.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' + +import type { AppsSettingsResponse } from '../../api/types' +import { SOFTWARE_FACTORY } from './api' +import { isDruksIssuesTracker, softwareFactoryNavigation } from './tracker' + +function settingsWithTracker(tracker: string): AppsSettingsResponse { + return { + allowedEfforts: [], + apps: [ + { + name: SOFTWARE_FACTORY, + settings: [{ name: 'tracker', value: tracker }], + }, + ], + } as unknown as AppsSettingsResponse +} + +describe('isDruksIssuesTracker', () => { + it('is true only when Software Factory tracker is issues', () => { + expect(isDruksIssuesTracker()).toBe(false) + expect(isDruksIssuesTracker({ allowedEfforts: [], apps: [] })).toBe(false) + expect(isDruksIssuesTracker(settingsWithTracker('linear'))).toBe(false) + expect(isDruksIssuesTracker(settingsWithTracker('jira'))).toBe(false) + expect(isDruksIssuesTracker(settingsWithTracker('none'))).toBe(false) + expect(isDruksIssuesTracker(settingsWithTracker('issues'))).toBe(true) + }) +}) + +describe('softwareFactoryNavigation', () => { + it('omits board until the tracker is druks', () => { + expect(softwareFactoryNavigation()).toEqual([ + [`/${SOFTWARE_FACTORY}`, 'Overview'], + [`/${SOFTWARE_FACTORY}/history`, 'history'], + [`/${SOFTWARE_FACTORY}/projects`, 'projects'], + ]) + expect(softwareFactoryNavigation(settingsWithTracker('linear'))).toEqual([ + [`/${SOFTWARE_FACTORY}`, 'Overview'], + [`/${SOFTWARE_FACTORY}/history`, 'history'], + [`/${SOFTWARE_FACTORY}/projects`, 'projects'], + ]) + }) + + it('inserts board after Overview when the tracker is druks', () => { + expect(softwareFactoryNavigation(settingsWithTracker('issues'))).toEqual([ + [`/${SOFTWARE_FACTORY}`, 'Overview'], + [`/${SOFTWARE_FACTORY}/board`, 'board'], + [`/${SOFTWARE_FACTORY}/history`, 'history'], + [`/${SOFTWARE_FACTORY}/projects`, 'projects'], + ]) + }) +}) diff --git a/frontend/src/apps/software_factory/tracker.ts b/frontend/src/apps/software_factory/tracker.ts new file mode 100644 index 00000000..d80aa3b2 --- /dev/null +++ b/frontend/src/apps/software_factory/tracker.ts @@ -0,0 +1,40 @@ +import { useQuery } from '@tanstack/react-query' + +import { api } from '../../api/client' +import type { AppsSettingsResponse } from '../../api/types' +import { SOFTWARE_FACTORY } from './api' + +/** The Settings value whose label is "druks" — this appliance's own board. */ +export const DRUKS_ISSUES_TRACKER = 'issues' + +export function isDruksIssuesTracker(settings?: AppsSettingsResponse): boolean { + const field = settings?.apps + .find((app) => app.name === SOFTWARE_FACTORY) + ?.settings.find((setting) => setting.name === 'tracker') + return field?.value === DRUKS_ISSUES_TRACKER +} + +export function softwareFactoryNavigation(settings?: AppsSettingsResponse): [string, string][] { + const overview: [string, string] = [`/${SOFTWARE_FACTORY}`, 'Overview'] + const history: [string, string] = [`/${SOFTWARE_FACTORY}/history`, 'history'] + const projects: [string, string] = [`/${SOFTWARE_FACTORY}/projects`, 'projects'] + if (isDruksIssuesTracker(settings)) { + return [ + overview, + [`/${SOFTWARE_FACTORY}/board`, 'board'], + history, + projects, + ] + } + return [overview, history, projects] +} + +export function useDruksIssuesTracker(): boolean | undefined { + const settings = useQuery({ + queryKey: ['appSettings'], + queryFn: api.getAppSettings, + staleTime: 60_000, + }) + if (settings.isPending) return undefined + return isDruksIssuesTracker(settings.data) +} diff --git a/frontend/src/apps/software_factory/ui.tsx b/frontend/src/apps/software_factory/ui.tsx index c6e3e478..3ed07e11 100644 --- a/frontend/src/apps/software_factory/ui.tsx +++ b/frontend/src/apps/software_factory/ui.tsx @@ -2,20 +2,18 @@ import { registerAppUI, targetQuery } from '../registry' import { SOFTWARE_FACTORY } from './api' import { parseLeadingId } from './slug' import { AgentCallPage } from './AgentCallPage' +import { DruksIssuesPage } from './DruksIssuesPage' import { HistoryPage } from './HistoryPage' import { NotFound } from './NotFound' import { ProjectsPage } from './projects/ProjectsPage' +import { softwareFactoryNavigation } from './tracker' import { WorkItemPage } from './WorkItemPage' import { WorkItemsPage } from './WorkItemsPage' registerAppUI({ name: SOFTWARE_FACTORY, home: `/${SOFTWARE_FACTORY}`, - navigation: [ - [`/${SOFTWARE_FACTORY}`, 'Overview'], - [`/${SOFTWARE_FACTORY}/history`, 'history'], - [`/${SOFTWARE_FACTORY}/projects`, 'projects'], - ], + navigationFor: softwareFactoryNavigation, // Software Factory's other subject, a project repo, has no page of its own — a row about one // stays unclickable rather than landing on the work item that shares its id. parentPath: (location) => { @@ -28,6 +26,11 @@ registerAppUI({ : undefined, routes: [ { path: `/${SOFTWARE_FACTORY}`, render: () => }, + { path: `/${SOFTWARE_FACTORY}/board`, render: () => }, + { + path: `/${SOFTWARE_FACTORY}/tickets/:identifier`, + render: () => , + }, { path: `/${SOFTWARE_FACTORY}/history`, render: () => }, { path: `/${SOFTWARE_FACTORY}/projects`, render: () => }, { diff --git a/frontend/src/druksui/AppPage.test.tsx b/frontend/src/druksui/AppPage.test.tsx index 0154c129..cb9172a2 100644 --- a/frontend/src/druksui/AppPage.test.tsx +++ b/frontend/src/druksui/AppPage.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { Router } from 'wouter' import { memoryLocation } from 'wouter/memory-location' @@ -83,11 +83,11 @@ const ROSTER = [ function renderAt(location: string, page: string, snapshot: PageSnapshot) { listApps.mockResolvedValue(ROSTER) readPage.mockResolvedValue(snapshot) - const { hook } = memoryLocation({ path: location }) + const memory = memoryLocation({ path: location, record: true }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) return render( - + , @@ -272,6 +272,34 @@ describe('the parent link', () => { await waitFor(() => expect(screen.getByText('Notes')).toBeTruthy()) expect(container.querySelector('.dui-parent')).toBeNull() }) + + it('rereads the page when a filter changes and strips the parked decision from the read', async () => { + const snapshot: PageSnapshot = { + ...NOTES, + filters: [ + { + field: 'select', + name: 'status', + label: 'Status', + helpText: '', + isRequired: false, + options: [ + { value: '', label: 'Any' }, + { value: 'todo', label: 'Todo' }, + ], + value: '', + }, + ], + } + renderAt('/field_notes?run=abc&parkedAt=x', 'notes', snapshot) + + await waitFor(() => expect(screen.getByLabelText('Status')).toBeTruthy()) + expect(readPage).toHaveBeenCalledWith('field_notes', '') + + fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'todo' } }) + + await waitFor(() => expect(readPage).toHaveBeenCalledWith('field_notes', '', 'status=todo')) + }) }) describe('a page snapshot the renderer cannot walk', () => { diff --git a/frontend/src/druksui/AppPage.tsx b/frontend/src/druksui/AppPage.tsx index 7cd54e14..c333cb0c 100644 --- a/frontend/src/druksui/AppPage.tsx +++ b/frontend/src/druksui/AppPage.tsx @@ -1,16 +1,28 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query' +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query' import { useMemo, useRef, type ReactNode } from 'react' -import { Link as RouteLink } from 'wouter' +import { Link as RouteLink, useLocation } from 'wouter' import { appLabel } from '../apps/registry' import { api } from '../api/client' -import type { Action, Follows, Link, PageEntry, PageSnapshot } from '../api/types' +import type { Action, Field, Follows, Link, PageEntry, PageSnapshot } from '../api/types' import { EmptyState } from '../components/EmptyState' import { Page } from '../components/Page' import { useRawLocation } from '../lib/useRawLocation' import { AppSurface } from './AppSurface' import { Blocks, Controls } from './Blocks' -import { followedSubjects, gateRuns, hrefUnder, isDetail, mergeRegions, PagesContext, parentOf, tabsFor } from './pages' +import { Fields } from './Fields' +import { + followedSubjects, + gateRuns, + hrefUnder, + isDetail, + mergeRegions, + pageFilterSearch, + pageQueryKey, + PagesContext, + parentOf, + tabsFor, +} from './pages' import { SubjectStream } from './SubjectStream' // The wait before each attempt at one refresh. Three tries, then the page @@ -31,10 +43,12 @@ export function AppPage({ app, page }: { app: string; page: string }) { const pages = installed?.pages ?? [] const operations = installed?.operations ?? [] const path = location.slice(`/${app}`.length) - const key = ['page', app, path] + const filters = pageFilterSearch(search) + const key = pageQueryKey(app, path, filters) const snapshot = useQuery({ queryKey: key, - queryFn: () => api.readPage(app, path), + queryFn: () => (filters ? api.readPage(app, path, filters) : api.readPage(app, path)), + placeholderData: keepPreviousData, // The stream is what keeps this page fresh. Without this, a background // refetch would write the cache outside the numbered reads below and could // land after a newer snapshot. @@ -53,7 +67,9 @@ export function AppPage({ app, page }: { app: string; page: string }) { // numbered, so a newer read still wins. for (const wait of REFRESH_WAITS) { if (wait) await new Promise((resume) => setTimeout(resume, wait)) - const fresh = await api.readPage(app, path).catch(() => undefined) + const fresh = await (filters ? api.readPage(app, path, filters) : api.readPage(app, path)).catch( + () => undefined, + ) if (mine !== latest.current.get(watched)) return if (fresh) { queryClient.setQueryData(key, (previous?: PageSnapshot) => @@ -131,6 +147,7 @@ export function AppPage({ app, page }: { app: string; page: string }) { title={snapshot.data.title} description={snapshot.data.description} controls={snapshot.data.controls} + filters={snapshot.data.filters ?? []} /> {target && !gateRuns(snapshot.data.blocks).includes(target.run) && (

This input request is unavailable. Return to the Dashboard to open the current request.

@@ -154,6 +171,7 @@ function PageChrome({ title, description, controls, + filters, }: { app: string page: string @@ -164,6 +182,7 @@ function PageChrome({ title: ReactNode description?: string controls?: (Action | Link)[] + filters?: Field[] }) { return ( <> @@ -199,10 +218,39 @@ function PageChrome({ ))} )} + {filters?.length ? : null} ) } +function PageFilters({ fields }: { fields: Field[] }) { + const { path: rawPath, search, base } = useRawLocation() + const location = rawPath.slice(base.length) + const [, navigate] = useLocation() + const values = Object.fromEntries( + fields.map((field) => [field.name, 'value' in field ? (field.value ?? '') : '']), + ) + return ( +
+ { + const query = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search) + const next = value == null || value === '' ? '' : String(value) + if (next) query.set(name, next) + else query.delete(name) + if (name === 'project') query.delete('repo') + const qs = query.toString() + navigate(`${location}${qs ? `?${qs}` : ''}`, { replace: true }) + }} + /> +
+ ) +} + function appError(app: string, detail: string, retry: () => void): ReactNode { return ( diff --git a/frontend/src/druksui/Blocks.test.tsx b/frontend/src/druksui/Blocks.test.tsx index 924de480..befed885 100644 --- a/frontend/src/druksui/Blocks.test.tsx +++ b/frontend/src/druksui/Blocks.test.tsx @@ -1,13 +1,31 @@ -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Router } from 'wouter' import { memoryLocation } from 'wouter/memory-location' -import type { Block, CardBlock, EmptyStateBlock, PageEntry } from '../api/types' +import { api } from '../api/client' +import type { Action, Block, CardBlock, EmptyStateBlock, Operation, PageEntry } from '../api/types' import { Blocks } from './Blocks' import { PagesContext } from './pages' -afterEach(cleanup) +vi.mock('../api/client', async () => { + const real = await vi.importActual('../api/client') + return { + ApiError: real.ApiError, + api: { callOperation: vi.fn(), readPage: vi.fn(), upload: vi.fn(), listApps: vi.fn() }, + } +}) + +const callOperation = vi.mocked(api.callOperation) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) +beforeEach(() => { + callOperation.mockResolvedValue() +}) const PAGES: PageEntry[] = [ { @@ -28,15 +46,25 @@ const PAGES: PageEntry[] = [ }, ] +const OPERATIONS: Operation[] = [ + { id: 'set_status', method: 'POST', path: '/api/software_factory/tickets/{identifier}/status' }, +] + function renderBlocks(blocks: Block[]) { - const { hook } = memoryLocation({ path: '/field_notes' }) - return render( - - - - - , + const location = memoryLocation({ path: '/field_notes', record: true }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const rendered = render( + + + + + + + , ) + return { ...rendered, location } } describe('the display core', () => { @@ -182,6 +210,53 @@ describe('Cards', () => { controls: [], } + function ticketCard(title: string, identifier: string): CardBlock { + return { + block: 'card', + title, + description: identifier, + blocks: [], + controls: [], + drag: { identifier }, + link: { + block: 'link', + label: title, + page: 'note', + arguments: { note_id: '7' }, + url: '', + subject: null, + }, + } + } + + function moveAction(status: string): Action { + return { + block: 'action', + label: `Move to ${status}`, + operation: 'set_status', + arguments: { status }, + fields: [], + tone: 'default', + confirm: '', + refresh: 'none', + link: null, + } + } + + function transfer() { + const data: Record = {} + return { + setData(type: string, value: string) { + data[type] = value + }, + getData(type: string) { + return data[type] ?? '' + }, + effectAllowed: 'move', + dropEffect: 'move', + } + } + it('shows one card for each thing, under the title', () => { const { container } = renderBlocks([ { block: 'cards', title: 'Peers', cards: [card('peer-7'), card('peer-9')], empty: null }, @@ -207,4 +282,258 @@ describe('Cards', () => { expect(container.textContent).toBe('') }) + + it('makes a linked card the destination, with no Open control', () => { + renderBlocks([ + { + block: 'card', + title: 'Ship the board', + description: 'DRU-1', + blocks: [], + controls: [], + link: { + block: 'link', + label: 'Ship the board', + page: 'note', + arguments: { note_id: '7' }, + url: '', + subject: null, + }, + }, + ]) + + const card = screen.getByText('Ship the board').closest('a') + expect(card?.getAttribute('href')).toBe('/field_notes/notes/7') + expect(card?.className).toContain('dui-card') + expect(screen.queryByText('Open')).toBeNull() + }) + + it('puts the link on the title when the card also has controls', () => { + renderBlocks([ + { + block: 'card', + title: 'Ship the board', + description: 'DRU-1', + blocks: [], + controls: [ + { + block: 'link', + label: 'Archive', + page: 'notes', + arguments: {}, + url: '', + subject: null, + }, + ], + link: { + block: 'link', + label: 'Ship the board', + page: 'note', + arguments: { note_id: '7' }, + url: '', + subject: null, + }, + }, + ]) + + expect(screen.getByText('Ship the board').closest('a')?.getAttribute('href')).toBe( + '/field_notes/notes/7', + ) + expect(screen.getByText('Ship the board').closest('.dui-card')?.tagName).toBe('DIV') + expect(screen.getByText('Archive').getAttribute('href')).toBe('/field_notes') + }) + + it('stacks cards in one column when layout is stack', () => { + const { container } = renderBlocks([ + { block: 'cards', title: 'Todo', layout: 'stack', cards: [card('one')], empty: null }, + ]) + + expect(container.querySelector('ul.dui-cards')?.className).toContain('dui-cards-stack') + }) + + it('posts the drop action with the card drag merged in', async () => { + const { container } = renderBlocks([ + { + block: 'cards', + title: 'Todo', + layout: 'stack', + drop: moveAction('todo'), + cards: [ticketCard('Ship', 'BOX-1')], + empty: null, + }, + { + block: 'cards', + title: 'Done', + layout: 'stack', + drop: moveAction('done'), + cards: [], + empty: nothingYet, + }, + ]) + const dt = transfer() + const dest = container.querySelectorAll('.dui-cards-drop')[1]! + fireEvent.dragStart(container.querySelector('ul.dui-cards li')!, { dataTransfer: dt }) + fireEvent.dragOver(screen.getByText('No peer yet'), { dataTransfer: dt }) + fireEvent.drop(dest, { dataTransfer: dt }) + + await waitFor(() => expect(callOperation).toHaveBeenCalled()) + expect(callOperation).toHaveBeenCalledWith( + 'POST', + '/api/software_factory/tickets/BOX-1/status', + { status: 'done' }, + ) + }) + + it('does not post when the card lands on its own list', () => { + const { container } = renderBlocks([ + { + block: 'cards', + title: 'Todo', + layout: 'stack', + drop: moveAction('todo'), + cards: [ticketCard('Ship', 'BOX-1')], + empty: null, + }, + ]) + const dt = transfer() + fireEvent.dragStart(container.querySelector('ul.dui-cards li')!, { dataTransfer: dt }) + fireEvent.drop(container.querySelector('.dui-cards-drop')!, { dataTransfer: dt }) + + expect(callOperation).not.toHaveBeenCalled() + }) + + it('does not follow the card link after a drag', () => { + const { container, location } = renderBlocks([ + { + block: 'cards', + title: 'Todo', + layout: 'stack', + drop: moveAction('todo'), + cards: [ticketCard('Ship', 'BOX-1')], + empty: null, + }, + ]) + const dt = transfer() + fireEvent.dragStart(container.querySelector('ul.dui-cards li')!, { dataTransfer: dt }) + fireEvent.click(screen.getByText('Ship')) + + expect(location.history).toEqual(['/field_notes']) + }) + + it('dims the card, then previews it in the list under the pointer', () => { + const { container } = renderBlocks([ + { + block: 'cards', + title: 'Todo', + layout: 'stack', + drop: moveAction('todo'), + cards: [ticketCard('Ship', 'BOX-1')], + empty: null, + }, + { + block: 'cards', + title: 'Done', + layout: 'stack', + drop: moveAction('done'), + cards: [], + empty: nothingYet, + }, + ]) + const dt = transfer() + const drops = container.querySelectorAll('.dui-cards-drop') + const source = drops[0]! + const dest = drops[1]! + fireEvent.dragStart(source.querySelector('ul.dui-cards li')!, { dataTransfer: dt }) + + expect(source.querySelector('.dui-cards-item-dim')).toBeTruthy() + expect(source.className).toContain('dui-cards-drop-live') + expect(dest.className).toContain('dui-cards-drop-live') + expect(container.querySelector('.dui-card-ghost')).toBeNull() + expect(callOperation).not.toHaveBeenCalled() + + fireEvent.dragOver(dest, { dataTransfer: dt }) + + expect(source.querySelector('.dui-cards-item-away')).toBeTruthy() + expect(dest.querySelector('.dui-card-ghost')?.textContent).toContain('Ship') + expect(dest.className).toContain('dui-cards-drop-over') + expect(dest.querySelector('[hidden]')).toBeTruthy() + expect(callOperation).not.toHaveBeenCalled() + }) + + it('restores the card when the drag ends without a drop', () => { + const { container } = renderBlocks([ + { + block: 'cards', + title: 'Todo', + layout: 'stack', + drop: moveAction('todo'), + cards: [ticketCard('Ship', 'BOX-1')], + empty: null, + }, + { + block: 'cards', + title: 'Done', + layout: 'stack', + drop: moveAction('done'), + cards: [], + empty: nothingYet, + }, + ]) + const dt = transfer() + const drops = container.querySelectorAll('.dui-cards-drop') + const source = drops[0]! + const dest = drops[1]! + const item = source.querySelector('ul.dui-cards li')! + fireEvent.dragStart(item, { dataTransfer: dt }) + fireEvent.dragOver(dest, { dataTransfer: dt }) + fireEvent.dragEnd(item, { dataTransfer: dt }) + + expect(container.querySelector('.dui-cards-item-dim')).toBeNull() + expect(container.querySelector('.dui-cards-item-away')).toBeNull() + expect(container.querySelector('.dui-card-ghost')).toBeNull() + expect(source.className).not.toContain('dui-cards-drop-live') + expect(screen.getByText('No peer yet')).toBeTruthy() + expect(callOperation).not.toHaveBeenCalled() + }) + + it('commits the list that held the placeholder, even if drop lands on a neighbor', async () => { + const { container } = renderBlocks([ + { + block: 'cards', + title: 'Todo', + layout: 'stack', + drop: moveAction('todo'), + cards: [ticketCard('Ship', 'BOX-1')], + empty: null, + }, + { + block: 'cards', + title: 'Ready for Agent', + layout: 'stack', + drop: moveAction('ready_for_agent'), + cards: [], + empty: nothingYet, + }, + { + block: 'cards', + title: 'In Progress', + layout: 'stack', + drop: moveAction('in_progress'), + cards: [], + empty: nothingYet, + }, + ]) + const dt = transfer() + const drops = container.querySelectorAll('.dui-cards-drop') + fireEvent.dragStart(drops[0]!.querySelector('ul.dui-cards li')!, { dataTransfer: dt }) + fireEvent.dragOver(drops[1]!, { dataTransfer: dt }) + fireEvent.drop(drops[2]!, { dataTransfer: dt }) + + await waitFor(() => expect(callOperation).toHaveBeenCalled()) + expect(callOperation).toHaveBeenCalledWith( + 'POST', + '/api/software_factory/tickets/BOX-1/status', + { status: 'ready_for_agent' }, + ) + }) }) diff --git a/frontend/src/druksui/Blocks.tsx b/frontend/src/druksui/Blocks.tsx index 570deea0..afd25798 100644 --- a/frontend/src/druksui/Blocks.tsx +++ b/frontend/src/druksui/Blocks.tsx @@ -1,12 +1,74 @@ -import { useContext } from 'react' +import { createContext, useContext, useEffect, useId, useRef, useSyncExternalStore } from 'react' +import { Link as RouteLink } from 'wouter' -import type { Action, Block, Link } from '../api/types' +import type { Action, Block, CardBlock, Link } from '../api/types' import { Markdown } from '../components/Markdown' import { GateControls } from './GateControls' import { Chart, Facts, ImageGallery, LinkControl, List, Metrics, Table } from './DataBlocks' -import { ActionButton, Form } from './Form' +import { ActionButton, Form, useAction } from './Form' import { Files, Image, Progress, Timeline } from './RunBlocks' -import { PagesContext, RegionContext } from './pages' +import { hrefForLink, PagesContext, RegionContext } from './pages' + +const CardsZoneContext = createContext('') + +type CardsDrag = { + source: string + over: string + card: CardBlock + payload: Record + accept: (payload: Record) => void +} + +let cardsDrag: CardsDrag | null = null +const cardsDragListeners = new Set<() => void>() + +function subscribeCardsDrag(listener: () => void) { + cardsDragListeners.add(listener) + return () => { + cardsDragListeners.delete(listener) + } +} + +function onWindowDragOver(event: DragEvent) { + if (!cardsDrag) return + const node = event.target instanceof Element ? event.target.closest('[data-cards-zone]') : null + if (node || cardsDrag.over === cardsDrag.source) return + setCardsDrag({ ...cardsDrag, over: cardsDrag.source }) +} + +function finishCardsDrop(raw: string) { + const current = cardsDrag + setCardsDrag(null) + if (!current || !raw || current.over === current.source) return + current.accept(JSON.parse(raw) as Record) +} + +function setCardsDrag(next: CardsDrag | null) { + if ( + cardsDrag === next || + (cardsDrag && + next && + cardsDrag.source === next.source && + cardsDrag.over === next.over && + cardsDrag.card === next.card) + ) { + return + } + const started = !cardsDrag && next + const ended = cardsDrag && !next + cardsDrag = next + if (started) window.addEventListener('dragover', onWindowDragOver) + if (ended) window.removeEventListener('dragover', onWindowDragOver) + cardsDragListeners.forEach((listener) => listener()) +} + +function useCardsDrag() { + return useSyncExternalStore(subscribeCardsDrag, () => cardsDrag) +} + +function isDragged(card: CardBlock, drag: CardsDrag) { + return JSON.stringify(card.drag ?? {}) === JSON.stringify(drag.payload) +} export function Blocks({ blocks }: { blocks: Block[] }) { return ( @@ -42,6 +104,8 @@ function BlockContent({ block }: { block: Block }) { description={block.description} fields={block.fields} action={block.action} + submit={block.submit ?? 'button'} + layout={block.layout ?? 'stack'} /> ) case 'gate_controls': @@ -102,7 +166,7 @@ function BlockContent({ block }: { block: Block }) { case 'columns': if (!block.blocks.length) return null return ( -
+
{block.blocks.map((column, index) => (
@@ -126,34 +190,10 @@ function BlockContent({ block }: { block: Block }) {
) case 'card': - return ( -
- {block.title &&
{block.title}
} - {block.description &&
{block.description}
} - - -
- ) - case 'cards': { - const inside = block.cards.length ? ( -
    - {block.cards.map((card, index) => ( -
  • - -
  • - ))} -
- ) : ( - block.empty && - ) - if (!inside) return null - return ( -
- {block.title &&

{block.title}

} - {inside} -
- ) - } + return + case 'cards': + if (!block.drop) return + return case 'section': { const decision = block.blocks.some((insideBlock) => insideBlock.block === 'gate_controls') return ( @@ -184,6 +224,200 @@ function BlockContent({ block }: { block: Block }) { } } +function CardPanel({ block }: { block: CardBlock }) { + const { app, pages } = useContext(PagesContext) + const wrapHref = block.link && !block.controls.length ? hrefForLink(block.link, app, pages) : '' + const title = block.title && ( + block.link && !wrapHref ? ( +
+ +
+ ) : ( +
{block.title}
+ ) + ) + const inner = ( + <> + {title} + {block.description &&
{block.description}
} + + + + ) + if (wrapHref && block.link) { + if (block.link.url) { + return ( + + {inner} + + ) + } + return ( + + {inner} + + ) + } + return
{inner}
+} + +function cardsClass(layout: 'wrap' | 'stack' | undefined) { + return `dui-cards${layout === 'stack' ? ' dui-cards-stack' : ''}` +} + +function CardsStatic({ + block, +}: { + block: Extract +}) { + const inside = block.cards.length ? ( +
    + {block.cards.map((card, index) => ( +
  • + +
  • + ))} +
+ ) : ( + block.empty && + ) + if (!inside) return null + return ( +
+ {block.title &&

{block.title}

} + {inside} +
+ ) +} + +function CardItem({ card }: { card: CardBlock }) { + const zone = useContext(CardsZoneContext) + const drag = useCardsDrag() + const skipClick = useRef(false) + const payload = card.drag ?? {} + const movable = Boolean(zone && Object.keys(payload).length) + const dragged = Boolean(drag && drag.source === zone && isDragged(card, drag)) + const away = Boolean(dragged && drag && drag.over !== zone) + return ( +
  • { + skipClick.current = true + event.dataTransfer.setData('application/json', JSON.stringify(payload)) + event.dataTransfer.setData('text/x-druks-zone', zone) + setCardsDrag({ + source: zone, + over: zone, + card, + payload, + accept: () => {}, + }) + } + : undefined + } + onDragEnd={() => setCardsDrag(null)} + onClickCapture={ + movable + ? (event) => { + if (!skipClick.current) return + event.preventDefault() + event.stopPropagation() + skipClick.current = false + } + : undefined + } + > + +
  • + ) +} + +function CardGhost({ card }: { card: CardBlock }) { + return ( + + ) +} + +function CardsDrop({ + block, + drop, +}: { + block: Extract + drop: Action +}) { + const run = useAction(drop) + const zone = useId() + const drag = useCardsDrag() + const hovering = drag?.over === zone + const holding = Boolean(drag && hovering && drag.source !== zone) + const inside = ( + <> + {block.cards.length || holding ? ( +
      + {block.cards.map((card, index) => ( + + ))} + {holding && drag ? : null} +
    + ) : null} + {block.empty && !block.cards.length ? ( + + ) : null} + + ) + useEffect(() => { + return () => { + if (cardsDrag?.source === zone) setCardsDrag(null) + } + }, [zone]) + return ( + +
    event.preventDefault()} + onDragOver={(event) => { + event.preventDefault() + if (!cardsDrag || cardsDrag.over === zone) return + setCardsDrag({ + ...cardsDrag, + over: zone, + accept: (payload) => { + void run.call(payload) + }, + }) + }} + onDrop={(event) => { + event.preventDefault() + event.stopPropagation() + finishCardsDrop(event.dataTransfer.getData('application/json')) + }} + > +
    + {block.title &&

    {block.title}

    } + {inside} +
    + {run.problem ? ( +
    + {run.problem} +
    + ) : null} +
    +
    + ) +} + export function Controls({ controls }: { controls: (Action | Link)[] }) { if (controls.length === 0) return null return ( diff --git a/frontend/src/druksui/DataBlocks.test.tsx b/frontend/src/druksui/DataBlocks.test.tsx index a6eba09e..19dbc297 100644 --- a/frontend/src/druksui/DataBlocks.test.tsx +++ b/frontend/src/druksui/DataBlocks.test.tsx @@ -208,7 +208,8 @@ describe('Table', () => { ]) expect(screen.getByText('No peers yet.')).toBeTruthy() - expect(screen.queryByRole('table')).toBeNull() + expect(screen.getByRole('table', { name: 'Peers' })).toBeTruthy() + expect(screen.getAllByRole('columnheader').map((one) => one.textContent)).toEqual(['Peer']) }) it('renders nothing at all when the app said nothing', () => { diff --git a/frontend/src/druksui/DataBlocks.tsx b/frontend/src/druksui/DataBlocks.tsx index fcc2db22..9563d68e 100644 --- a/frontend/src/druksui/DataBlocks.tsx +++ b/frontend/src/druksui/DataBlocks.tsx @@ -1,4 +1,4 @@ -import { useContext, useState } from 'react' +import { useContext, useId, useState } from 'react' import { Link as RouteLink } from 'wouter' import type { @@ -13,7 +13,7 @@ import type { } from '../api/types' import { RelTime } from '../components/RelTime' import { Image, Status } from './RunBlocks' -import { fillPath, PagesContext } from './pages' +import { hrefForLink, PagesContext } from './pages' // The plot's own coordinates; CSS gives it its real size. const PLOT_WIDTH = 300 @@ -76,26 +76,14 @@ function TextDatum({ which shows the value's own text. */ export function LinkControl({ link, label = link.label }: { link: Link; label?: string }) { const { app, pages } = useContext(PagesContext) + const href = hrefForLink(link, app, pages) if (link.url) { return ( - + {label} ) } - if (link.subject) { - // The subject's own platform page — the full story of what druks did. - return ( - - {label} - - ) - } - const target = pages.find((entry) => entry.name === link.page) - const href = target ? fillPath(target.path, link.arguments) : '' if (href) { return ( @@ -310,24 +298,27 @@ export function Table({ rows: TableRow[] emptyText: string }) { - if (rows.length === 0) { - // Nothing to show and nothing to say about it: a heading over an empty box - // is worse than no block at all. - if (!emptyText) return null - return ( -
    - {title &&

    {title}

    } -
    {emptyText}
    -
    - ) - } + const headingId = useId() + if (rows.length === 0 && !emptyText) return null return (
    + {title && ( +

    + {title} +

    + )}
    - - {/* The title names the table itself, so a reader moving between - tables hears which one it is. */} - {title && } +
    {title}
    + + {columns.map((column) => ( + + ))} + {columns.map((column) => ( @@ -338,9 +329,15 @@ export function Table({ - {rows.map((row, index) => ( - - ))} + {rows.length === 0 ? ( + + + + ) : ( + rows.map((row, index) => ) + )}
    + {emptyText} +
    diff --git a/frontend/src/druksui/Fields.tsx b/frontend/src/druksui/Fields.tsx index 53d83800..0faeb77b 100644 --- a/frontend/src/druksui/Fields.tsx +++ b/frontend/src/druksui/Fields.tsx @@ -1,6 +1,38 @@ -import { useId, useRef } from 'react' +import { useId, useRef, type ReactNode } from 'react' -import type { Field } from '../api/types' +import type { Field, Option } from '../api/types' +import { MarkdownEditor } from './MarkdownEditor' + +function groupedOptions(options: Option[]): { group: string; options: Option[] }[] { + const groups: { group: string; options: Option[] }[] = [] + for (const option of options) { + const group = option.group ?? '' + const last = groups.at(-1) + if (last && last.group === group) last.options.push(option) + else groups.push({ group, options: [option] }) + } + return groups +} + +function selectOptions(options: Option[]): ReactNode { + return groupedOptions(options).map((entry) => + entry.group ? ( + + {entry.options.map((option) => ( + + ))} + + ) : ( + entry.options.map((option) => ( + + )) + ), + ) +} /** Every input in a form, with the value the operator has given it so far and * whatever the server said about it. */ @@ -10,12 +42,14 @@ export function Fields({ errors, resets, onChange, + onBlur, }: { fields: Field[] values: Record errors: Record resets: number onChange: (name: string, value: unknown) => void + onBlur?: () => void }) { // A page can hold two forms that both take a "body", so the id a label points // at belongs to this form, not to the field name alone. @@ -52,6 +86,9 @@ export function Fields({ id={id} value={values[field.name]} onChange={onChange} + onBlur={ + ['text', 'text_area', 'number', 'secret'].includes(field.field) ? onBlur : undefined + } describedBy={describedBy} isInvalid={Boolean(errors[field.name])} resets={resets} @@ -78,6 +115,7 @@ function Input({ id, value, onChange, + onBlur, describedBy, isInvalid, resets, @@ -86,6 +124,7 @@ function Input({ id: string value: unknown onChange: (name: string, value: unknown) => void + onBlur?: () => void describedBy?: string isInvalid: boolean resets: number @@ -115,9 +154,24 @@ function Input({ placeholder={field.placeholder} value={String(value ?? '')} onChange={(event) => onChange(field.name, event.target.value)} + onBlur={onBlur} /> ) case 'text_area': + if (field.markdown) { + return ( + + ) + } return (