feat: add environments — parametric ticket ID prefixes, env selector UI, MCP tools

- Environment model (prefix, name, description) with CRUD API
- Ticket.human_id now computed from environment prefix (e.g. PROJ-001)
- Migration 0002: environments table + backfill existing tickets to HOME env
- Router: list/create/get/patch/delete environments; list_tickets + create_ticket accept environment_id
- MCP: list_environments, create_environment tools; environment_id added to list_tickets + create_ticket
- UI: env pill selector with dropdown, new-environment modal, filter tickets by env

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
caoimhinr 2026-05-25 20:14:59 +02:00
parent 2a7686f398
commit d5f3ce0667
5 changed files with 442 additions and 40 deletions

View file

@ -0,0 +1,45 @@
"""Add environments table and environment_id FK on tickets
Revision ID: 0002
Revises: 0001
Create Date: 2026-05-25
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0002"
down_revision: Union[str, None] = "0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"environments",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("prefix", sa.String(16), nullable=False, unique=True),
sa.Column("name", sa.String(64), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
)
op.execute("INSERT INTO environments (prefix, name) VALUES ('HOME', 'Homelab')")
op.add_column("tickets",
sa.Column("environment_id", sa.Integer(),
sa.ForeignKey("environments.id", ondelete="SET NULL"), nullable=True))
op.execute(
"UPDATE tickets SET environment_id = (SELECT id FROM environments WHERE prefix = 'HOME')"
)
op.create_index("ix_tickets_environment_id", "tickets", ["environment_id"])
def downgrade() -> None:
op.drop_index("ix_tickets_environment_id", table_name="tickets")
op.drop_column("tickets", "environment_id")
op.drop_table("environments")

View file

@ -9,6 +9,19 @@ class Base(DeclarativeBase):
pass
class Environment(Base):
__tablename__ = "environments"
id: Mapped[int] = mapped_column(primary_key=True)
prefix: Mapped[str] = mapped_column(String(16), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(64), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc))
tickets: Mapped[list["Ticket"]] = relationship("Ticket", back_populates="environment")
class TicketAttachment(Base):
__tablename__ = "ticket_attachments"
@ -39,6 +52,7 @@ class Ticket(Base):
parent_id: Mapped[int | None] = mapped_column(ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True)
effort: Mapped[int | None] = mapped_column(Integer, nullable=True)
startup_script: Mapped[str | None] = mapped_column(Text, nullable=True)
environment_id: Mapped[int | None] = mapped_column(ForeignKey("environments.id", ondelete="SET NULL"), nullable=True)
attachments: Mapped[list["TicketAttachment"]] = relationship(
"TicketAttachment",
@ -47,7 +61,13 @@ class Ticket(Base):
order_by="TicketAttachment.created_at",
lazy="selectin",
)
environment: Mapped["Environment | None"] = relationship(
"Environment",
back_populates="tickets",
lazy="selectin",
)
@property
def human_id(self) -> str:
return f"HOME-{self.id:03d}"
prefix = self.environment.prefix if self.environment else "HOME"
return f"{prefix}-{self.id:03d}"

View file

@ -3,16 +3,30 @@ import uuid
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
from pydantic import BaseModel
from sqlalchemy import or_, select
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from .models import Ticket, TicketAttachment
from .models import Environment, Ticket, TicketAttachment
VALID_STATUSES = {"open", "ongoing", "completed", "abandoned"}
VALID_TYPES = {"feature", "bug", "chore", "project"}
VALID_USERS = {"kevin", "claude"}
# ── Pydantic schemas ──────────────────────────────────────────────────────────
class EnvironmentCreate(BaseModel):
prefix: str
name: str
description: str | None = None
class EnvironmentUpdate(BaseModel):
prefix: str | None = None
name: str | None = None
description: str | None = None
class TicketCreate(BaseModel):
user: str = "kevin"
title: str
@ -22,6 +36,7 @@ class TicketCreate(BaseModel):
parent_id: int | None = None
effort: int | None = None
startup_script: str | None = None
environment_id: int | None = None
class TicketUpdate(BaseModel):
@ -33,6 +48,7 @@ class TicketUpdate(BaseModel):
parent_id: int | None = None
effort: int | None = None
startup_script: str | None = None
environment_id: int | None = None
class AttachmentCreate(BaseModel):
@ -46,6 +62,18 @@ class AttachmentUpdate(BaseModel):
content: str | None = None
# ── Serialisers ───────────────────────────────────────────────────────────────
def _env_dict(e: Environment) -> dict:
return {
"id": e.id,
"prefix": e.prefix,
"name": e.name,
"description": e.description,
"created_at": e.created_at.isoformat() if e.created_at else None,
}
async def _ticket_dict(t: Ticket, db: AsyncSession) -> dict:
child_ids = (await db.execute(
select(Ticket.id).where(Ticket.parent_id == t.id).order_by(Ticket.id)
@ -65,6 +93,7 @@ async def _ticket_dict(t: Ticket, db: AsyncSession) -> dict:
"attachment_count": len(t.attachments),
"effort": t.effort,
"startup_script": t.startup_script,
"environment_id": t.environment_id,
}
@ -79,6 +108,8 @@ def _att_dict(a: TicketAttachment) -> dict:
}
# ── Helpers ───────────────────────────────────────────────────────────────────
async def _get_ticket_or_404(db: AsyncSession, ticket_id: int) -> Ticket:
t = (await db.execute(select(Ticket).where(Ticket.id == ticket_id))).scalar_one_or_none()
if not t:
@ -86,9 +117,23 @@ async def _get_ticket_or_404(db: AsyncSession, ticket_id: int) -> Ticket:
return t
async def _get_env_or_404(db: AsyncSession, env_id: int) -> Environment:
e = await db.get(Environment, env_id)
if not e:
raise HTTPException(404, "environment not found")
return e
async def _default_env_id(db: AsyncSession) -> int | None:
e = (await db.execute(select(Environment).order_by(Environment.id))).scalar_one_or_none()
return e.id if e else None
# ── Router factory ────────────────────────────────────────────────────────────
def make_router(api_key: str, get_db, require_user=None) -> APIRouter:
"""
Return a FastAPI APIRouter with all /api/tickets/* endpoints.
Return a FastAPI APIRouter with all /api/environments/* and /api/tickets/* endpoints.
Args:
api_key: Value of TICKETS_API_KEY requests presenting this in
@ -115,10 +160,81 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter:
if require_user is not None:
return require_user(request)
if not api_key:
# No key configured — open / local mode
return {"username": "local", "email": "local@localhost"}
raise HTTPException(401, "unauthorized")
# ── Environment endpoints ─────────────────────────────────────────────────
@router.get("/api/environments")
async def list_environments(
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
rows = (await db.execute(select(Environment).order_by(Environment.id))).scalars().all()
return [_env_dict(e) for e in rows]
@router.post("/api/environments", status_code=201)
async def create_environment(
body: EnvironmentCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
prefix = body.prefix.strip().upper()
if not prefix or len(prefix) > 16:
raise HTTPException(400, "prefix must be 116 characters")
existing = (await db.execute(
select(Environment).where(Environment.prefix == prefix)
)).scalar_one_or_none()
if existing:
raise HTTPException(409, f"prefix '{prefix}' already exists")
e = Environment(prefix=prefix, name=body.name.strip(), description=body.description)
db.add(e)
await db.commit()
await db.refresh(e)
return _env_dict(e)
@router.get("/api/environments/{env_id}")
async def get_environment(
env_id: int,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
return _env_dict(await _get_env_or_404(db, env_id))
@router.patch("/api/environments/{env_id}")
async def update_environment(
env_id: int,
body: EnvironmentUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
e = await _get_env_or_404(db, env_id)
if body.prefix is not None:
e.prefix = body.prefix.strip().upper()
if body.name is not None:
e.name = body.name.strip()
if body.description is not None:
e.description = body.description
await db.commit()
await db.refresh(e)
return _env_dict(e)
@router.delete("/api/environments/{env_id}")
async def delete_environment(
env_id: int,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
e = await _get_env_or_404(db, env_id)
count = (await db.execute(
select(func.count()).select_from(Ticket).where(Ticket.environment_id == env_id)
)).scalar()
if count:
raise HTTPException(409, f"environment has {count} ticket(s); reassign before deleting")
await db.delete(e)
await db.commit()
return {"ok": True}
# ── Ticket endpoints ──────────────────────────────────────────────────────
@router.get("/api/tickets")
@ -126,10 +242,13 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter:
status: list[str] = Query(default=[]),
type: list[str] = Query(default=[]),
q: str = Query(default=""),
environment_id: int | None = Query(default=None),
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
stmt = select(Ticket).order_by(Ticket.submitted_at.desc())
if environment_id is not None:
stmt = stmt.where(Ticket.environment_id == environment_id)
if status:
stmt = stmt.where(Ticket.status.in_(status))
if type:
@ -156,6 +275,11 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter:
raise HTTPException(400, "effort must be between 1 and 10")
if body.parent_id is not None:
await _get_ticket_or_404(db, body.parent_id)
env_id = body.environment_id
if env_id is not None:
await _get_env_or_404(db, env_id)
else:
env_id = await _default_env_id(db)
t = Ticket(
guid=str(uuid.uuid4()),
user=body.user,
@ -166,6 +290,7 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter:
parent_id=body.parent_id,
effort=body.effort,
startup_script=body.startup_script,
environment_id=env_id,
)
db.add(t)
await db.commit()
@ -215,6 +340,9 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter:
t.effort = body.effort
if "startup_script" in body.model_fields_set:
t.startup_script = body.startup_script
if "environment_id" in body.model_fields_set and body.environment_id is not None:
await _get_env_or_404(db, body.environment_id)
t.environment_id = body.environment_id
await db.commit()
return await _ticket_dict(await _get_ticket_or_404(db, ticket_id), db)

View file

@ -209,11 +209,52 @@ textarea.desc-input {
pointer-events: none;
}
.search-match { background: #4f8ef733; border-radius: 2px; }
/* ── Environment selector ── */
.env-selector { position: relative; display: inline-block; }
.env-pill {
display: flex; align-items: center; gap: 6px;
padding: 5px 12px; border-radius: 20px;
border: 1px solid var(--primary); background: #4f8ef710;
color: var(--primary); font-size: 12px; font-weight: 600;
cursor: pointer; transition: background .15s;
}
.env-pill:hover { background: #4f8ef720; opacity: 1; }
.env-dropdown {
display: none; position: absolute; top: calc(100% + 4px); left: 0;
background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; box-shadow: 0 4px 16px #0006;
min-width: 210px; z-index: 200; overflow: hidden;
}
.env-dropdown.open { display: block; }
.env-dd-item {
padding: 8px 14px; font-size: 12px; cursor: pointer;
display: flex; align-items: center; gap: 8px; transition: background .1s;
}
.env-dd-item:hover { background: var(--raised); }
.env-dd-item.active { color: var(--primary); font-weight: 600; }
.env-dd-divider { border-top: 1px solid var(--divider); margin: 2px 0; }
.env-prefix-badge {
font-family: monospace; font-size: 10px; background: var(--raised);
padding: 1px 5px; border-radius: 3px; color: var(--fg-sec); flex-shrink: 0;
}
</style>
{% endblock %}
{% block content %}
<!-- Environment selector -->
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<div class="env-selector" id="env-selector">
<button class="env-pill" id="env-pill" onclick="toggleEnvDropdown(); event.stopPropagation()">
<span id="env-pill-name"></span>
<span style="font-size:9px;opacity:.7"></span>
</button>
<div class="env-dropdown" id="env-dropdown"></div>
</div>
<button class="btn btn-subtle btn-sm" onclick="openEnvCreate(); event.stopPropagation()">+ Env</button>
</div>
<!-- Toolbar -->
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:16px; flex-wrap:wrap;">
<div style="display:flex; flex-direction:column; gap:6px;">
@ -391,6 +432,31 @@ textarea.desc-input {
</div>
</div>
<!-- New Environment modal -->
<div class="overlay" id="env-create-overlay">
<div class="modal" style="min-width:400px" onclick="event.stopPropagation()">
<h3>New Environment</h3>
<div class="modal-fields">
<div class="field">
<label>Prefix <span class="muted" style="font-weight:400;font-size:11px">(e.g. PROJ, LAB — max 16 chars)</span></label>
<input type="text" id="env-prefix" placeholder="HOME" autocomplete="off" style="text-transform:uppercase" maxlength="16">
</div>
<div class="field">
<label>Name</label>
<input type="text" id="env-name" placeholder="My Project" autocomplete="off">
</div>
<div class="field">
<label>Description <span class="muted" style="font-weight:400;font-size:11px">(optional)</span></label>
<input type="text" id="env-desc" placeholder="Short description…" autocomplete="off">
</div>
</div>
<div class="actions">
<button class="btn btn-subtle" onclick="closeEnvCreate()">Cancel</button>
<button class="btn btn-primary" onclick="submitEnvCreate()">Create</button>
</div>
</div>
</div>
<!-- Edit modal -->
<div class="overlay" id="edit-overlay">
<div class="modal" style="min-width:520px" onclick="event.stopPropagation()">
@ -462,6 +528,8 @@ let expandedParents = new Set();
let currentAttach = null;
let tickets = [];
let attachments = [];
let environments = [];
let currentEnvironmentId = null;
const STATUS_COLORS = {
open: 'badge-open',
@ -518,12 +586,104 @@ function fmt_date(iso) {
return d.toLocaleDateString('en-IE', {year:'numeric',month:'short',day:'numeric'});
}
// ── Environment selector ─────────────────────────────────────────────────────
async function loadEnvironments() {
try {
environments = await api('/api/environments');
if (!environments.length) { await loadTickets(); return; }
if (currentEnvironmentId === null || !environments.find(e => e.id === currentEnvironmentId)) {
currentEnvironmentId = environments[0].id;
}
renderEnvPill();
renderEnvDropdown();
await loadTickets();
} catch(err) {
toast(err.message, true);
}
}
function renderEnvPill() {
const env = environments.find(e => e.id === currentEnvironmentId);
document.getElementById('env-pill-name').textContent = env
? `${env.prefix} · ${env.name}` : '…';
}
function renderEnvDropdown() {
const dd = document.getElementById('env-dropdown');
dd.innerHTML = environments.map(e => `
<div class="env-dd-item${e.id === currentEnvironmentId ? ' active' : ''}"
onclick="selectEnvironment(${e.id}); event.stopPropagation()">
<span class="env-prefix-badge">${escHtml(e.prefix)}</span>
<span style="flex:1">${escHtml(e.name)}</span>
</div>
`).join('') + `
<div class="env-dd-divider"></div>
<div class="env-dd-item" onclick="openEnvCreate(); closeEnvDropdown(); event.stopPropagation()">
<span style="color:var(--primary);font-weight:600">+ New environment</span>
</div>
`;
}
function toggleEnvDropdown() {
document.getElementById('env-dropdown').classList.toggle('open');
}
function closeEnvDropdown() {
document.getElementById('env-dropdown').classList.remove('open');
}
function selectEnvironment(id) {
currentEnvironmentId = id;
closeDetail();
closeEnvDropdown();
renderEnvPill();
renderEnvDropdown();
loadTickets();
}
function openEnvCreate() {
document.getElementById('env-prefix').value = '';
document.getElementById('env-name').value = '';
document.getElementById('env-desc').value = '';
document.getElementById('env-create-overlay').classList.add('active');
document.getElementById('env-prefix').focus();
}
function closeEnvCreate() {
document.getElementById('env-create-overlay').classList.remove('active');
}
async function submitEnvCreate() {
const prefix = document.getElementById('env-prefix').value.trim().toUpperCase();
const name = document.getElementById('env-name').value.trim();
if (!prefix) { toast('Prefix is required', true); return; }
if (!name) { toast('Name is required', true); return; }
try {
const created = await api('/api/environments', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ prefix, name, description: document.getElementById('env-desc').value.trim() || null }),
});
environments.push(created);
currentEnvironmentId = created.id;
renderEnvPill();
renderEnvDropdown();
closeEnvCreate();
await loadTickets();
toast('Environment created');
} catch(err) {
toast(err.message, true);
}
}
async function loadTickets() {
try {
const parts = [
...[...activeFilters].map(s => `status=${encodeURIComponent(s)}`),
...[...activeTypeFilters].map(t => `type=${encodeURIComponent(t)}`),
];
if (currentEnvironmentId !== null) parts.push(`environment_id=${currentEnvironmentId}`);
const url = parts.length ? `/api/tickets?${parts.join('&')}` : '/api/tickets';
tickets = await api(url);
renderList();
@ -718,6 +878,7 @@ async function submitCreate() {
user: document.getElementById('c-user').value,
parent_id: parentVal ? parseInt(parentVal) : null,
effort: effortVal ? parseInt(effortVal) : null,
environment_id: currentEnvironmentId,
}),
});
closeCreate();
@ -968,6 +1129,8 @@ document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
if (document.getElementById('att-edit-overlay').classList.contains('active')) closeAttachEdit();
else if (document.getElementById('att-view-overlay').classList.contains('active')) closeAttachView();
else if (document.getElementById('env-create-overlay').classList.contains('active')) closeEnvCreate();
else if (document.getElementById('env-dropdown').classList.contains('open')) closeEnvDropdown();
else if (document.getElementById('edit-overlay').classList.contains('active')) closeEdit();
else if (document.getElementById('create-overlay').classList.contains('active')) closeCreate();
else if (searchQuery) {
@ -995,7 +1158,13 @@ document.getElementById('att-view-overlay').addEventListener('click', e => {
document.getElementById('att-edit-overlay').addEventListener('click', e => {
if (e.target === e.currentTarget) closeAttachEdit();
});
document.getElementById('env-create-overlay').addEventListener('click', e => {
if (e.target === e.currentTarget) closeEnvCreate();
});
loadTickets();
document.addEventListener('click', () => closeEnvDropdown());
document.getElementById('env-selector').addEventListener('click', e => e.stopPropagation());
loadEnvironments();
</script>
{% endblock %}

View file

@ -91,10 +91,41 @@ def _check(r: httpx.Response) -> dict | list:
return r.json()
# ── Environment tools ─────────────────────────────────────────────────────────
@mcp.tool()
def list_environments() -> list[dict]:
"""
List all ticket environments (prefix + name). Each environment scopes a
set of tickets with its own ID prefix (e.g. HOME-001, PROJ-001).
"""
with _client() as c:
return _check(c.get("/api/environments"))
@mcp.tool()
def create_environment(prefix: str, name: str, description: str = "") -> dict:
"""
Create a new ticket environment.
Args:
prefix: Short uppercase identifier, e.g. PROJ or LAB (max 16 chars).
name: Human-readable name, e.g. "My Project".
description: Optional longer description.
"""
with _client() as c:
return _check(c.post("/api/environments", json={
"prefix": prefix.upper(),
"name": name,
"description": description or None,
}))
# ── Ticket tools ──────────────────────────────────────────────────────────────
@mcp.tool()
def list_tickets(status: str = "", type: str = "", q: str = "") -> list[dict]:
def list_tickets(status: str = "", type: str = "", q: str = "",
environment_id: int = 0) -> list[dict]:
"""
List homelab tickets. Results include attachment_count so you know
which tickets have supporting documents without fetching them.
@ -106,6 +137,7 @@ def list_tickets(status: str = "", type: str = "", q: str = "") -> list[dict]:
Leave empty to list all types.
q: Full-text search query matched against ticket title and
description (case-insensitive). Leave empty to skip.
environment_id: Filter by environment ID. Pass 0 (default) for all environments.
"""
with _client() as c:
params = {}
@ -115,6 +147,8 @@ def list_tickets(status: str = "", type: str = "", q: str = "") -> list[dict]:
params["type"] = type
if q:
params["q"] = q
if environment_id:
params["environment_id"] = environment_id
return _check(c.get("/api/tickets", params=params))
@ -140,6 +174,7 @@ def create_ticket(
user: str = "claude",
parent_id: int = 0,
effort: int = 0,
environment_id: int = 0,
) -> dict:
"""
Create a new homelab ticket. For longer supporting documents
@ -154,6 +189,9 @@ def create_ticket(
user: Who is submitting kevin or claude (default: claude).
parent_id: ID of the parent ticket (omit or pass 0 for no parent).
effort: Estimated effort rating 110 (omit or pass 0 to leave unset).
environment_id: Environment to file the ticket in. Pass 0 to use the
default environment (HOME). Use list_environments() to
find available environments.
"""
body: dict = {
"title": title,
@ -166,6 +204,8 @@ def create_ticket(
body["parent_id"] = parent_id
if effort:
body["effort"] = effort
if environment_id:
body["environment_id"] = environment_id
with _client() as c:
ticket = _check(c.post("/api/tickets", json=body))
script = _generate_startup_script(