diff --git a/backend/druks/contrib/issues/__init__.py b/backend/druks/contrib/issues/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/druks/contrib/issues/app.py b/backend/druks/contrib/issues/app.py new file mode 100644 index 00000000..aeb49cc0 --- /dev/null +++ b/backend/druks/contrib/issues/app.py @@ -0,0 +1,13 @@ +from druks.apps import App + + +class Issues(App): + name = "issues" + icon = "layers" + description = "A local issue board — projects, tickets, and comments the appliance owns." + # Every table this app owns carries the ``issues_`` prefix, so the board's + # schema can never collide with core's or another app's. + prefix_tables = True + # The board is the landing page; the list gets the second tab. The ticket + # page is parameterized, so a Link reaches it rather than a tab. + navigation = ["board", "list"] diff --git a/backend/druks/contrib/issues/enums.py b/backend/druks/contrib/issues/enums.py new file mode 100644 index 00000000..95b89165 --- /dev/null +++ b/backend/druks/contrib/issues/enums.py @@ -0,0 +1,54 @@ +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" + TODO = "todo" + READY_FOR_AGENT = "ready_for_agent" + IN_PROGRESS = "in_progress" + IN_REVIEW = "in_review" + DONE = "done" + CANCELLED = "cancelled" + + @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. Cancelled is finished but not completed — the + difference is what a funnel counts.""" + 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 in (Status.DONE, Status.CANCELLED) + + +# 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.TODO: "Todo", + Status.READY_FOR_AGENT: "Ready for Agent", + Status.IN_PROGRESS: "In Progress", + Status.IN_REVIEW: "In Review", + Status.DONE: "Done", + Status.CANCELLED: "Cancelled", +} + + +class Priority(StrEnum): + NONE = "none" + URGENT = "urgent" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" diff --git a/backend/druks/contrib/issues/exceptions.py b/backend/druks/contrib/issues/exceptions.py new file mode 100644 index 00000000..3fecef9c --- /dev/null +++ b/backend/druks/contrib/issues/exceptions.py @@ -0,0 +1,16 @@ +class ProjectNotFound(Exception): + def __init__(self, project_id: int) -> None: + super().__init__(f"project {project_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") + + +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" + ) diff --git a/backend/druks/contrib/issues/migrations/__init__.py b/backend/druks/contrib/issues/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/druks/contrib/issues/migrations/versions/__init__.py b/backend/druks/contrib/issues/migrations/versions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/druks/contrib/issues/migrations/versions/issues_0001_projects_tickets_comments.py b/backend/druks/contrib/issues/migrations/versions/issues_0001_projects_tickets_comments.py new file mode 100644 index 00000000..46b55d5d --- /dev/null +++ b/backend/druks/contrib/issues/migrations/versions/issues_0001_projects_tickets_comments.py @@ -0,0 +1,69 @@ +"""issues: projects, tickets, and comments + +Revision ID: issues_0001 +Revises: +Create Date: 2026-09-02 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# This app owns an independent migration history — its own +# alembic_version_issues table, never linked to core's revisions. +revision = "issues_0001" +down_revision = None +branch_labels = None +depends_on = 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), + # The monotonic ticket sequence, bumped in place when an identifier is + # minted and never decremented. + 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", + # Integer subject key (StoredSubject.id) — serial, matching create_all. + 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/druks/contrib/issues/models.py b/backend/druks/contrib/issues/models.py new file mode 100644 index 00000000..2b92c89b --- /dev/null +++ b/backend/druks/contrib/issues/models.py @@ -0,0 +1,249 @@ +import re +from datetime import datetime + +import sqlalchemy as sa +from sqlalchemy import ForeignKey, String, select +from sqlalchemy.orm import Mapped, mapped_column, validates + +from druks.contrib.issues.enums import Priority, Status +from druks.contrib.issues.exceptions import InvalidPrefix, PrefixLocked, ProjectNotFound +from druks.contrib.issues.schemas import TicketSummary +from druks.db import Base, StoredSubject, db_session + +# A project's prefix is the identifier namespace — Linear's team key. Short +# enough to read at a glance, long enough to stay distinct. +PREFIX_PATTERN = "^[A-Z]{2,6}$" +PREFIX_RE = re.compile(PREFIX_PATTERN) + + +def normalize_prefix(prefix: str) -> str: + """The stored form of an operator's prefix: uppercase, and 2-6 letters or + nothing at all.""" + normalized = prefix.strip().upper() + if not PREFIX_RE.match(normalized): + raise InvalidPrefix(prefix) + return normalized + + +class Project(Base): + __tablename__ = "issues_projects" + # The prefix shape lives in the database too: validation covers this app's + # own doors, the constraint covers everything else that can write the row. + __table_args__ = ( + sa.CheckConstraint(f"prefix ~ '{PREFIX_PATTERN}'", name="issues_projects_prefix_shape"), + ) + + # Not a StoredSubject: no run is ever *about* a project — runs are about the + # tickets it namespaces. + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(unique=True) + prefix: Mapped[str] = mapped_column(String(6), unique=True) + # The monotonic ticket sequence. It only ever goes up: it is bumped inside + # the INSERT 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) + + @validates("prefix") + def _normalize_prefix(self, key: str, prefix: str) -> str: + # Every assignment path — create, an edit, a fixture — normalizes and + # validates, so an unshaped prefix can't reach the column. + return normalize_prefix(prefix) + + @classmethod + async def create(cls, *, name: str, prefix: str) -> "Project": + session = db_session() + project = cls(name=name, prefix=prefix) + session.add(project) + # A duplicate name or prefix surfaces here, as the unique violation it + # is: the board refuses two namespaces that spell the same thing. + await session.flush() + return project + + @classmethod + async def get(cls, project_id: int) -> "Project | None": + return await db_session().get(cls, project_id) + + @classmethod + async def list(cls) -> list["Project"]: + statement = select(cls).order_by(cls.created_at, cls.id) + return list(await db_session().scalars(statement)) + + @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.""" + statement = ( + sa.update(cls) + .where(cls.id == project_id) + .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 not row: + raise ProjectNotFound(project_id) + prefix, number = row + return f"{prefix}-{number}" + + async def set_prefix(self, prefix: str) -> 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. + if minted and normalize_prefix(prefix) != self.prefix: + raise PrefixLocked(self.prefix) + self.prefix = prefix + await session.flush() + + +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.TODO) + priority: Mapped[str] = mapped_column(default=Priority.NONE) + # Required: a ticket without a namespace could not be named. + project_id: Mapped[int] = mapped_column(ForeignKey("issues_projects.id")) + # Optional: a ticket exists before anyone picks it up. + assignee_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, + *, + project_id: int, + title: str, + description: str = "", + status: Status = Status.TODO, + priority: Priority = Priority.NONE, + assignee_id: str | None = None, + ) -> "Ticket": + session = db_session() + ticket = cls( + identifier=await Project.mint_identifier(project_id), + project_id=project_id, + title=title, + description=description, + status=status, + priority=priority, + assignee_id=assignee_id, + ) + session.add(ticket) + await session.flush() + 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_board(cls) -> list["Ticket"]: + """Everything on the board — cancelled tickets are off it. The page + groups these by status; the model just says which rows are live.""" + statement = ( + select(cls) + .where(cls.status != Status.CANCELLED) + .order_by(cls.updated_at.desc(), cls.id.desc()) + ) + return list(await db_session().scalars(statement)) + + @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 set_priority(self, priority: Priority) -> None: + self.priority = priority + self.updated_at = Base.utc_now() + await db_session().flush() + + async def assign(self, assignee_id: str | None) -> None: + self.assignee_id = assignee_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/issues/pages.py b/backend/druks/contrib/issues/pages.py new file mode 100644 index 00000000..61791697 --- /dev/null +++ b/backend/druks/contrib/issues/pages.py @@ -0,0 +1,383 @@ +from druks import ui +from druks.accounts.models import Account +from druks.contrib.issues.enums import Priority, Status +from druks.contrib.issues.models import Comment, Project, Ticket + +# The board's columns, worked-on left to right. Cancelled is not a column: a +# cancelled ticket is off the board, which is what ``Ticket.list_board`` reads. +BOARD_STATUSES = ( + Status.BACKLOG, + Status.TODO, + Status.READY_FOR_AGENT, + Status.IN_PROGRESS, + Status.IN_REVIEW, + Status.DONE, +) +# The list's sections, worked-on first, with the finished ones at the bottom. +LIST_STATUSES = ( + Status.IN_PROGRESS, + Status.IN_REVIEW, + Status.READY_FOR_AGENT, + Status.TODO, + Status.BACKLOG, + Status.DONE, + Status.CANCELLED, +) + +# 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", +} +# How a status reads as a chip. Presentation only — the workflow is the enum. +STATUS_TONES: dict[Status, str] = { + Status.BACKLOG: "neutral", + Status.TODO: "neutral", + Status.READY_FOR_AGENT: "warning", + Status.IN_PROGRESS: "active", + Status.IN_REVIEW: "active", + Status.DONE: "success", + Status.CANCELLED: "danger", +} + +UNASSIGNED = "Unassigned" +# An account that has since gone, or druks' own system actor: the row still +# reads, it just carries no name. +UNATTRIBUTED = "Unattributed" + + +def _project_options(projects: list[Project]) -> list[ui.Option]: + """Every namespace a ticket can be minted into. No blank entry: a ticket + without a project could not be named.""" + return [ui.Option(project.name, value=str(project.id)) for project in projects] + + +def _assignee_options(accounts: list[Account]) -> list[ui.Option]: + """Who work can be handed to, plus nobody. Unassigned carries the empty + value the doors read back as "no assignee".""" + return [ui.Option(UNASSIGNED, 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 _assignee_name(assignee_id: str | None, account_names: dict[str, str]) -> str: + if not assignee_id: + return UNASSIGNED + return account_names.get(assignee_id, UNATTRIBUTED) + + +def _new_ticket_action(projects: list[Project], accounts: list[Account]) -> ui.Action: + """Creation is a control on the board, not a destination: a page that lists + nothing is not where a ticket gets written.""" + return ui.Action( + label="New ticket", + operation="issues_create_ticket", + tone="primary", + fields=[ + ui.TextField(name="title", label="Title", is_required=True), + ui.SelectField( + name="project_id", + label="Project", + options=_project_options(projects), + is_required=True, + help_text="The namespace the identifier is minted from.", + ), + ui.TextAreaField(name="description", label="Description"), + ui.SelectField( + name="status", + label="Status", + options=_status_options(), + value=Status.TODO.value, + ), + ui.SelectField( + name="priority", + label="Priority", + options=_priority_options(), + value=Priority.NONE.value, + ), + ui.SelectField( + name="assignee_id", + label="Assignee", + options=_assignee_options(accounts), + ), + ], + ) + + +def _new_project_action() -> ui.Action: + return ui.Action( + label="New project", + operation="issues_create_project", + fields=[ + ui.TextField(name="name", label="Name", is_required=True), + ui.TextField( + name="prefix", + label="Prefix", + is_required=True, + help_text="2-6 letters, A-Z — the first half of every identifier it mints.", + ), + ], + ) + + +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.assignee_id: + description.append(_assignee_name(ticket.assignee_id, account_names)) + return ui.Card( + title=ticket.title, + description=" · ".join(description), + controls=[ + ui.Link("Open", page="ticket", arguments={"identifier": ticket.identifier}), + ], + ) + + +def _ticket_row( + ticket: Ticket, + project_names: dict[int, str], + account_names: dict[str, str], +) -> ui.TableRow: + return ui.TableRow( + [ + ui.TextValue( + ticket.identifier, + link=ui.Link( + ticket.identifier, + page="ticket", + arguments={"identifier": ticket.identifier}, + ), + ), + ui.TextValue(ticket.title), + ui.TextValue(PRIORITY_LABELS[Priority(ticket.priority)]), + ui.TextValue(_assignee_name(ticket.assignee_id, account_names)), + ui.TextValue(project_names.get(ticket.project_id, "")), + ui.TimeValue(ticket.updated_at), + ] + ) + + +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 + ] + + +@ui.page("/") +async def board(): + tickets = await Ticket.list_board() + projects = await Project.list() + accounts = await Account.list_non_system() + account_names = {account.id: account.username for account in accounts} + return ui.Page( + "Board", + description="What this install is working on, a column to a status.", + # Built from the projects and accounts alone, so an empty install still + # offers both: the board is where a first ticket gets written. + controls=[_new_ticket_action(projects, accounts), _new_project_action()], + blocks=[ + ui.Columns( + [ + ui.Section( + title=status.label, + blocks=[ + ui.Cards( + # One read of the board, grouped here: the + # column is the status, and the model already + # answered in updated_at order. + cards=[ + _ticket_card(ticket, account_names) + for ticket in tickets + if ticket.status == status + ], + empty=ui.EmptyState( + "Nothing here", + description=f"No ticket is in {status.label}.", + ), + ) + ], + ) + for status 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")], + ) + ], + ) + + projects = await Project.list() + accounts = await Account.list_non_system() + project_names = {project.id: project.name for project in projects} + account_names = {account.id: account.username for account in accounts} + status = Status(found.status) + comments = await found.list_comments() + thread = _comment_blocks(comments, account_names) or [ + ui.EmptyState("No comments yet", description="Say something about this ticket.") + ] + + return ui.Page( + found.title, + description=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.Action( + label="Move", + operation="issues_set_status", + arguments={"identifier": found.identifier}, + fields=[ + ui.SelectField( + name="status", + label="Status", + options=_status_options(), + value=status.value, + is_required=True, + ) + ], + ) + ], + blocks=[ + ui.Markdown(found.description or "_No description._"), + ui.Facts( + [ + ui.Fact( + "Status", value=ui.StatusValue(status.label, tone=STATUS_TONES[status]) + ), + ui.Fact( + "Priority", + value=ui.TextValue(PRIORITY_LABELS[Priority(found.priority)]), + ), + ui.Fact( + "Assignee", + value=ui.TextValue(_assignee_name(found.assignee_id, account_names)), + ), + ui.Fact( + "Project", + value=ui.TextValue(project_names.get(found.project_id, "")), + ), + ui.Fact("Identifier", value=ui.TextValue(found.identifier)), + ], + title="Details", + ), + ui.Form( + title="Edit", + fields=[ + ui.TextField(name="title", label="Title", value=found.title, is_required=True), + ui.TextAreaField( + name="description", label="Description", value=found.description + ), + ui.SelectField( + name="priority", + label="Priority", + options=_priority_options(), + value=found.priority, + ), + ui.SelectField( + name="assignee_id", + label="Assignee", + options=_assignee_options(accounts), + value=found.assignee_id or "", + ), + ui.SelectField( + name="project_id", + label="Project", + options=_project_options(projects), + value=str(found.project_id), + ), + ], + action=ui.Action( + label="Save", + operation="issues_update_ticket", + arguments={"identifier": found.identifier}, + ), + ), + 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)], + action=ui.Action( + label="Comment", + operation="issues_add_comment", + arguments={"identifier": found.identifier}, + tone="primary", + refresh="region", + ), + ), + ], + ), + ], + ) + + +# Declared last: the name is the page's name, and binding it shadows the +# builtin for the rest of the module. +@ui.page("/list") +async def list(): + projects = await Project.list() + accounts = await Account.list_non_system() + project_names = {project.id: project.name for project in projects} + account_names = {account.id: account.username for account in accounts} + sections = [] + for status in LIST_STATUSES: + rows = await Ticket.list_for_status(status) + sections.append( + ui.Table( + title=status.label, + columns=[ + ui.TableColumn("Identifier"), + ui.TableColumn("Title"), + ui.TableColumn("Priority"), + ui.TableColumn("Assignee"), + ui.TableColumn("Project"), + ui.TableColumn("Updated", align="end"), + ], + rows=[_ticket_row(row, project_names, account_names) for row in rows], + empty_text=f"No ticket is in {status.label}.", + ) + ) + return ui.Page( + "List", + description="Every ticket, worked-on first and cancelled last.", + blocks=[ui.Stack(sections)], + ) diff --git a/backend/druks/contrib/issues/routes.py b/backend/druks/contrib/issues/routes.py new file mode 100644 index 00000000..0f1181ed --- /dev/null +++ b/backend/druks/contrib/issues/routes.py @@ -0,0 +1,249 @@ +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.issues.app import Issues +from druks.contrib.issues.enums import Priority, Status +from druks.contrib.issues.exceptions import InvalidPrefix +from druks.contrib.issues.models import Project, Ticket +from druks.contrib.issues.schemas import CommentRead, ProjectRead, TicketDetail, TicketEdit +from druks.db import Base, db_session +from druks.signals import publish + +# 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 ``issues_*``. +# The platform's free subject read-side owns the bare subject-type segment +# (/ticket), so this app's own doors live beside it under /tickets. 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_assignee(assignee_id: str) -> None: + """A ticket is assigned to a real, non-system account or to nobody. The + assignee 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. The system account is druks' own actor, never someone to hand work to.""" + if not await Account.get(assignee_id, exclude_system=True): + raise HTTPException(http_status.HTTP_404_NOT_FOUND, f"no account {assignee_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), + project_id=ticket.project_id, + assignee_id=ticket.assignee_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( + "/projects", + status_code=http_status.HTTP_201_CREATED, + operation_id="issues_create_project", + tags=["agent"], +) +async def create_project( + name: str = Body(..., embed=True, max_length=140), + prefix: str = Body( + ..., + embed=True, + description="2-6 letters, A-Z — the first half of every identifier this project mints", + ), +) -> ProjectRead: + """Open a namespace: a project names its tickets ``{prefix}-1``, + ``{prefix}-2``, and so on. The prefix is fixed once a number has been + handed out, so pick the one the team already says out loud.""" + name = required_text(name, "name") + try: + # The model's own @validates uppercases and shapes the prefix; a + # namespace nobody could spell is the caller's mistake, not a 500. + project = await Project.create(name=name, prefix=prefix) + except InvalidPrefix as error: + raise HTTPException(http_status.HTTP_422_UNPROCESSABLE_CONTENT, str(error)) from error + return ProjectRead.model_validate(project) + + +@router.post( + "/tickets", + status_code=http_status.HTTP_201_CREATED, + operation_id="issues_create_ticket", + tags=["agent"], +) +async def create_ticket( + title: str = Body(..., embed=True, max_length=200), + project_id: int = Body(..., embed=True, description="the namespace to mint from"), + description: str = Body("", embed=True), + status: Status = Body(Status.TODO, embed=True), + priority: Priority = Body(Priority.NONE, embed=True), + assignee_id: str | None = Body(None, embed=True), +) -> TicketDetail: + """Write a ticket down. It lands in Todo and takes the next number in its + project's sequence. Creating is quiet: moving a ticket into Ready for Agent + is what opens a build, so a new ticket publishes nothing.""" + title = required_text(title, "title") + if not await Project.get(project_id): + raise HTTPException(http_status.HTTP_404_NOT_FOUND, f"no project {project_id}") + # An assignee select with nobody picked submits "", and the shell sends + # every field the form shows. Blank is nobody, not an account id to look up. + assignee_id = assignee_id or None + if assignee_id is not None: + await require_assignee(assignee_id) + ticket = await Ticket.create( + project_id=project_id, + title=title, + description=description, + status=status, + priority=priority, + assignee_id=assignee_id, + ) + return await ticket_detail(ticket) + + +@router.patch("/tickets/{identifier}", operation_id="issues_update_ticket", tags=["agent"]) +async def update_ticket(identifier: str, edit: TicketEdit) -> TicketDetail: + """Edit what a ticket says — title, description, priority, assignee. 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.""" + ticket = await require_ticket(identifier) + if edit.assignee_id is not None: + await require_assignee(edit.assignee_id) + + 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 assignee 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 assignee_id means "unassign", so this field reads the caller's + # set of fields rather than the value: omitted keeps whoever holds it. + if "assignee_id" in edit.model_fields_set: + await ticket.assign(edit.assignee_id) + return await ticket_detail(ticket) + + +@router.post("/tickets/{identifier}/status", operation_id="issues_set_status", tags=["agent"]) +async def set_status( + identifier: str, + status: Status = Body(..., embed=True), +) -> TicketDetail: + """Move a ticket. This is the only door that moves one, and the only one + that 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) + if ticket.status == status: + # Already there: a repeat is not a transition, and re-firing would + # dispatch a second build for one move. + return await ticket_detail(ticket) + + await ticket.set_status(status) + project = await Project.get(ticket.project_id) + assignee = await Account.get(ticket.assignee_id) if ticket.assignee_id else None + await publish( + "ticket.transitioned", + payload={ + "source": "issues", + "identifier": ticket.identifier, + # The display label, the way Linear and Jira publish their state + # names: the funnel's trigger status is spelled as a human reads it. + "status": status.label, + "title": ticket.title, + # The shell serves an app's pages under the app's own name, so this + # is the path a link in a build or a notification opens. + "url": f"/{Issues.name}/tickets/{ticket.identifier}", + "project_name": project.name if project else None, + "labels": [], + # An account is a username and nothing else — no display name to + # tell apart from the address, so both keys carry the one name. + "assignee_email": assignee.username if assignee else None, + "assignee_name": assignee.username if assignee else None, + "completed": status.completed, + "terminal": status.terminal, + }, + ) + return await ticket_detail(ticket) + + +@router.post( + "/tickets/{identifier}/comments", + status_code=http_status.HTTP_201_CREATED, + operation_id="issues_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="issues_get_ticket", tags=["agent"]) +async def get_ticket(identifier: str) -> TicketDetail: + """Read one ticket: what it asks for, and everything said about it so far, + oldest comment first.""" + return await ticket_detail(await require_ticket(identifier)) diff --git a/backend/druks/contrib/issues/schemas.py b/backend/druks/contrib/issues/schemas.py new file mode 100644 index 00000000..c5af787b --- /dev/null +++ b/backend/druks/contrib/issues/schemas.py @@ -0,0 +1,71 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, field_validator + +from druks.contrib.issues.enums import Priority, Status +from druks.workflows import SubjectSummary + + +class TicketSummary(SubjectSummary): + # The ticket's domain header — what only issues knows. ``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 ProjectRead(BaseModel): + """A namespace as a door answers it — the prefix is what every identifier + minted against this project starts with.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + prefix: str + + +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 + project_id: int + assignee_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 ``assignee_id`` is the one null that says + something: it unassigns.""" + + title: str | None = None + description: str | None = None + priority: Priority | None = None + assignee_id: str | None = None + + @field_validator("assignee_id", mode="before") + @classmethod + def _blank_is_nobody(cls, value: str | None) -> str | None: + # An assignee select with nobody picked submits "", and the shell sends + # every field the form shows. Blank means unassign, not an account id to + # look up — the field still counts as given, so it still unassigns. + return value or None diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 99b9abbb..b9c22dca 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -115,6 +115,7 @@ def browser_session_declarations(): "test_provider_login_persistence", "test_app_migrations", "test_proof_app_migration", + "test_issues_migration", } diff --git a/backend/tests/issues/__init__.py b/backend/tests/issues/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/issues/test_models.py b/backend/tests/issues/test_models.py new file mode 100644 index 00000000..f5a9a5ca --- /dev/null +++ b/backend/tests/issues/test_models.py @@ -0,0 +1,99 @@ +import pytest +from druks.accounts.models import Account +from druks.apps.loader import get_app +from druks.contrib.issues.enums import Status +from druks.contrib.issues.exceptions import InvalidPrefix, PrefixLocked, ProjectNotFound +from druks.contrib.issues.models import Comment, Project, Ticket +from sqlalchemy.exc import IntegrityError + + +def test_issues_app_is_bundled_with_prefixed_tables(): + app = get_app("issues") + assert app.name == "issues" + assert app.prefix_tables is True + assert app.table_prefix == "issues_" + + +async def test_ticket_identifiers_are_monotonic_per_project_and_never_reused(): + dru = await Project.create(name="druks", prefix="dru") + first = await Ticket.create(project_id=dru.id, title="one") + second = await Ticket.create(project_id=dru.id, title="two") + assert first.identifier == "DRU-1" + assert second.identifier == "DRU-2" + + await first.delete() + third = await Ticket.create(project_id=dru.id, title="three") + assert third.identifier == "DRU-3" + + eng = await Project.create(name="engine", prefix="eng") + other = await Ticket.create(project_id=eng.id, title="eng-first") + assert other.identifier == "ENG-1" + + +async def test_unknown_project_refuses_a_ticket(): + with pytest.raises(ProjectNotFound): + await Ticket.create(project_id=0, title="orphan") + + +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(IntegrityError): + await Project.create(name="other", prefix="alp") + + +async def test_prefix_must_be_two_to_six_letters(): + with pytest.raises(InvalidPrefix): + await Project.create(name="short", prefix="A") + with pytest.raises(InvalidPrefix): + await Project.create(name="digits", prefix="DR1") + + +async def test_prefix_cannot_change_after_a_ticket_is_minted(): + project = await Project.create(name="locked", prefix="lok") + await project.set_prefix("lokx") + assert project.prefix == "LOKX" + + await Ticket.create(project_id=project.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") + project = await Project.create(name="thread", prefix="thd") + ticket = await Ticket.create(project_id=project.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_omits_cancelled(): + project = await Project.create(name="board", prefix="brd") + live = await Ticket.create(project_id=project.id, title="live") + gone = await Ticket.create(project_id=project.id, title="gone") + await gone.set_status(Status.CANCELLED) + + board = await Ticket.list_board() + identifiers = {ticket.identifier for ticket in board} + assert live.identifier in identifiers + assert gone.identifier not 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" + assert found.get_summary().status is Status.TODO diff --git a/backend/tests/issues/test_routes.py b/backend/tests/issues/test_routes.py new file mode 100644 index 00000000..ba6c08aa --- /dev/null +++ b/backend/tests/issues/test_routes.py @@ -0,0 +1,199 @@ +from druks.accounts.constants import SYSTEM_ACCOUNT_ID +from druks.accounts.models import Account +from druks.api.server import app as api +from druks.contrib.issues.enums import Status + + +def _published(monkeypatch): + events = [] + + async def emit(name, **kwargs): + events.append((name, kwargs["payload"])) + + monkeypatch.setattr("druks.contrib.issues.routes.publish", emit) + return events + + +async def _open_project(druks_client, *, name="druks", prefix="dru"): + created = await druks_client.post("/api/issues/projects", json={"name": name, "prefix": prefix}) + assert created.status_code == 201 + return created.json() + + +async def _open_ticket(druks_client, project_id, **fields): + created = await druks_client.post( + "/api/issues/tickets", + json={"title": "one", "project_id": project_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"]["/api/issues/tickets/{identifier}"]["get"] + add_comment = schema["paths"]["/api/issues/tickets/{identifier}/comments"]["post"] + assert "agent" in get_ticket["tags"] + assert get_ticket["operationId"] == "issues_get_ticket" + assert "agent" in add_comment["tags"] + assert add_comment["operationId"] == "issues_add_comment" + + +async def test_create_does_not_publish(druks_client, monkeypatch): + events = _published(monkeypatch) + project = await _open_project(druks_client) + ticket = await _open_ticket( + druks_client, project["id"], status="ready_for_agent", title="quiet" + ) + + assert ticket["identifier"] == "DRU-1" + assert ticket["status"] == "ready_for_agent" + assert ticket["comments"] == [] + assert events == [] + + +async def test_set_status_publishes_one_transition_with_display_labels(druks_client, monkeypatch): + events = _published(monkeypatch) + project = await _open_project(druks_client, name="acme-app") + ticket = await _open_ticket(druks_client, project["id"], title="Add an endpoint") + + moved = await druks_client.post( + f"/api/issues/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": "/issues/tickets/DRU-1", + "project_name": "acme-app", + "labels": [], + "assignee_email": None, + "assignee_name": None, + "completed": False, + "terminal": False, + }, + ) + ] + + again = await druks_client.post( + f"/api/issues/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_cancelled_terminal(druks_client, monkeypatch): + events = _published(monkeypatch) + project = await _open_project(druks_client) + ticket = await _open_ticket(druks_client, project["id"]) + + done = await druks_client.post( + f"/api/issues/tickets/{ticket['identifier']}/status", + json={"status": "done"}, + ) + cancelled = await druks_client.post( + f"/api/issues/tickets/{ticket['identifier']}/status", + json={"status": "cancelled"}, + ) + + assert done.status_code == 200 + assert cancelled.status_code == 200 + assert [payload["status"] for _, payload in events] == ["Done", "Cancelled"] + assert [payload["completed"] for _, payload in events] == [True, False] + assert [payload["terminal"] for _, payload in events] == [True, True] + + +async def test_update_ticket_never_publishes_and_cannot_set_status(druks_client, monkeypatch): + events = _published(monkeypatch) + project = await _open_project(druks_client) + ticket = await _open_ticket(druks_client, project["id"], title="old") + + edited = await druks_client.patch( + f"/api/issues/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"] == "todo" + assert events == [] + + +async def test_add_comment_authors_from_the_request_account(druks_client): + account = await Account.get_or_create("op@example.com") + project = await _open_project(druks_client) + ticket = await _open_ticket(druks_client, project["id"]) + + written = await druks_client.post( + f"/api/issues/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"/api/issues/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): + project = await _open_project(druks_client) + + created = await druks_client.post( + "/api/issues/tickets", + json={"title": " ", "project_id": project["id"]}, + ) + assert created.status_code == 422 + + ticket = await _open_ticket(druks_client, project["id"]) + edited = await druks_client.patch( + f"/api/issues/tickets/{ticket['identifier']}", + json={"title": " "}, + ) + assert edited.status_code == 422 + + commented = await druks_client.post( + f"/api/issues/tickets/{ticket['identifier']}/comments", + json={"body": "\n"}, + ) + assert commented.status_code == 422 + + +async def test_unknown_ticket_and_system_assignee_are_404(druks_client): + missing = await druks_client.get("/api/issues/tickets/DRU-99") + assert missing.status_code == 404 + + project = await _open_project(druks_client) + assigned = await druks_client.post( + "/api/issues/tickets", + json={ + "title": "handed to the system", + "project_id": project["id"], + "assignee_id": SYSTEM_ACCOUNT_ID, + }, + ) + assert assigned.status_code == 404 + + ticket = await _open_ticket(druks_client, project["id"]) + updated = await druks_client.patch( + f"/api/issues/tickets/{ticket['identifier']}", + json={"assignee_id": SYSTEM_ACCOUNT_ID}, + ) + assert updated.status_code == 404 + + gone = await druks_client.post("/api/issues/tickets/NOPE-1/status", json={"status": "done"}) + assert gone.status_code == 404 diff --git a/backend/tests/test_app_loader.py b/backend/tests/test_app_loader.py index d912b598..061379ee 100644 --- a/backend/tests/test_app_loader.py +++ b/backend/tests/test_app_loader.py @@ -11,6 +11,7 @@ def test_import_app_models_registers_software_factory_via_generic_discovery(): from druks.models import Base assert get_app("software_factory").prefix_tables is False + assert get_app("issues").prefix_tables is True import_app_models() # idempotent; raises if the unprefixed tables aren't exempt assert {"projects", "work_items", "project_repos"} <= set(Base.metadata.tables) diff --git a/backend/tests/test_app_roster.py b/backend/tests/test_app_roster.py index 3580395d..abd25fb5 100644 --- a/backend/tests/test_app_roster.py +++ b/backend/tests/test_app_roster.py @@ -15,6 +15,13 @@ def test_roster_lists_installed_apps_with_subject_types(tmp_path: Path): # Software Factory's pages are React, so its tabs live in its frontend. assert software_factory["navigation"] == [] assert software_factory["icon"] + issues = roster["issues"] + assert issues["builtin"] is False + assert issues["hasFrontend"] is False + assert issues["icon"] == "layers" + # No workflow yet — subjects follow from workflows, not from StoredSubject alone. + assert issues["subjectTypes"] == [] + field_notes = roster["field_notes"] assert field_notes["subjectTypes"] == ["note"] # Derived from the landing page the app declares, labelled by that page. diff --git a/backend/tests/test_apps.py b/backend/tests/test_apps.py index 1aa138d5..7d42c426 100644 --- a/backend/tests/test_apps.py +++ b/backend/tests/test_apps.py @@ -24,7 +24,12 @@ def _subjects(cls) -> list[type[Subject]]: def test_iter_apps_discovers_the_bundled_apps(): """The bundled apps resolve from the ``druks.apps`` entry points.""" - assert {app.name for app in iter_apps()} >= {"core", "software_factory", "usage"} + assert {app.name for app in iter_apps()} >= { + "core", + "software_factory", + "issues", + "usage", + } def test_platform_apps_are_builtin(): diff --git a/backend/tests/test_issues_migration.py b/backend/tests/test_issues_migration.py new file mode 100644 index 00000000..0851c3a8 --- /dev/null +++ b/backend/tests/test_issues_migration.py @@ -0,0 +1,49 @@ +from pathlib import Path + +from alembic import command +from alembic.config import Config +from druks.testing import TEST_DATABASE_URL, init_db +from sqlalchemy import create_engine + +_ALEMBIC_INI = Path(__file__).resolve().parent.parent / "alembic.ini" +_VERSIONS = ( + Path(__file__).resolve().parent.parent + / "druks" + / "contrib" + / "issues" + / "migrations" + / "versions" +) +_TABLES = "issues_comments, issues_tickets, issues_projects, alembic_version_issues" + + +def _config() -> Config: + config = Config(str(_ALEMBIC_INI)) + config.set_main_option("version_locations", str(_VERSIONS)) + config.set_main_option("sqlalchemy.url", TEST_DATABASE_URL) + config.attributes["version_table"] = "alembic_version_issues" + return config + + +def _drop(conn) -> None: + conn.exec_driver_sql(f"DROP TABLE IF EXISTS {_TABLES}") + + +def test_issues_migration_applies_under_its_own_version_table(request): + engine = create_engine(TEST_DATABASE_URL, isolation_level="AUTOCOMMIT") + with engine.connect() as conn: + _drop(conn) + try: + command.upgrade(_config(), "head") + with engine.connect() as conn: + assert conn.exec_driver_sql("SELECT to_regclass('issues_projects')").scalar() + assert conn.exec_driver_sql("SELECT to_regclass('issues_tickets')").scalar() + assert conn.exec_driver_sql("SELECT to_regclass('issues_comments')").scalar() + head = conn.exec_driver_sql("SELECT version_num FROM alembic_version_issues").scalar() + assert head == "issues_0001" + finally: + with engine.connect() as conn: + _drop(conn) + init_db(engine) + engine.dispose() + request.getfixturevalue("_druks_engine").dispose() diff --git a/backend/tests/test_mcp_endpoint.py b/backend/tests/test_mcp_endpoint.py index ae31807e..7b7819e4 100644 --- a/backend/tests/test_mcp_endpoint.py +++ b/backend/tests/test_mcp_endpoint.py @@ -164,7 +164,16 @@ async def test_tools_list_pins_platform_and_app_tools(app, pat_token): tools = {tool.name: tool for tool in await client.list_tools()} assert list(tools)[:7] == _TOOL_NAMES - assert list(tools)[7:] == ["review_request", "software_factory_start"] + assert list(tools)[7:] == [ + "issues_create_project", + "issues_create_ticket", + "issues_get_ticket", + "issues_update_ticket", + "issues_set_status", + "issues_add_comment", + "review_request", + "software_factory_start", + ] expected_annotations = { "cancel_run": (False, True, True), diff --git a/pyproject.toml b/pyproject.toml index 7f165459..731a769c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,7 @@ druks = "druks.testing" core = "druks.core.app:Core" review = "druks.contrib.review.app:Review" software_factory = "druks.contrib.software_factory.app:SoftwareFactory" +issues = "druks.contrib.issues.app:Issues" usage = "druks.usage.app:Usage" [build-system] @@ -102,7 +103,7 @@ ignore = ["SIM108"] # FastAPI dependency markers are the documented way to declare injected # parameters; B008 (no function calls in argument defaults) is a false # positive for them. -extend-immutable-calls = ["fastapi.Depends", "fastapi.Query"] +extend-immutable-calls = ["fastapi.Depends", "fastapi.Query", "fastapi.Body"] [tool.pytest.ini_options] testpaths = ["backend/tests"]