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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
13 changes: 13 additions & 0 deletions backend/druks/contrib/issues/app.py
Original file line number Diff line number Diff line change
@@ -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"]
54 changes: 54 additions & 0 deletions backend/druks/contrib/issues/enums.py
Original file line number Diff line number Diff line change
@@ -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"
16 changes: 16 additions & 0 deletions backend/druks/contrib/issues/exceptions.py
Original file line number Diff line number Diff line change
@@ -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"
)
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -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")
Loading