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 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): class TicketAttachment(Base):
__tablename__ = "ticket_attachments" __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) parent_id: Mapped[int | None] = mapped_column(ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True)
effort: Mapped[int | None] = mapped_column(Integer, nullable=True) effort: Mapped[int | None] = mapped_column(Integer, nullable=True)
startup_script: Mapped[str | None] = mapped_column(Text, 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( attachments: Mapped[list["TicketAttachment"]] = relationship(
"TicketAttachment", "TicketAttachment",
@ -47,7 +61,13 @@ class Ticket(Base):
order_by="TicketAttachment.created_at", order_by="TicketAttachment.created_at",
lazy="selectin", lazy="selectin",
) )
environment: Mapped["Environment | None"] = relationship(
"Environment",
back_populates="tickets",
lazy="selectin",
)
@property @property
def human_id(self) -> str: 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 fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from .models import Ticket, TicketAttachment from .models import Environment, Ticket, TicketAttachment
VALID_STATUSES = {"open", "ongoing", "completed", "abandoned"} VALID_STATUSES = {"open", "ongoing", "completed", "abandoned"}
VALID_TYPES = {"feature", "bug", "chore", "project"} VALID_TYPES = {"feature", "bug", "chore", "project"}
VALID_USERS = {"kevin", "claude"} 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): class TicketCreate(BaseModel):
user: str = "kevin" user: str = "kevin"
title: str title: str
@ -22,6 +36,7 @@ class TicketCreate(BaseModel):
parent_id: int | None = None parent_id: int | None = None
effort: int | None = None effort: int | None = None
startup_script: str | None = None startup_script: str | None = None
environment_id: int | None = None
class TicketUpdate(BaseModel): class TicketUpdate(BaseModel):
@ -33,6 +48,7 @@ class TicketUpdate(BaseModel):
parent_id: int | None = None parent_id: int | None = None
effort: int | None = None effort: int | None = None
startup_script: str | None = None startup_script: str | None = None
environment_id: int | None = None
class AttachmentCreate(BaseModel): class AttachmentCreate(BaseModel):
@ -46,6 +62,18 @@ class AttachmentUpdate(BaseModel):
content: str | None = None 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: async def _ticket_dict(t: Ticket, db: AsyncSession) -> dict:
child_ids = (await db.execute( child_ids = (await db.execute(
select(Ticket.id).where(Ticket.parent_id == t.id).order_by(Ticket.id) 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), "attachment_count": len(t.attachments),
"effort": t.effort, "effort": t.effort,
"startup_script": t.startup_script, "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: 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() t = (await db.execute(select(Ticket).where(Ticket.id == ticket_id))).scalar_one_or_none()
if not t: if not t:
@ -86,9 +117,23 @@ async def _get_ticket_or_404(db: AsyncSession, ticket_id: int) -> Ticket:
return t 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: 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: Args:
api_key: Value of TICKETS_API_KEY requests presenting this in 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: if require_user is not None:
return require_user(request) return require_user(request)
if not api_key: if not api_key:
# No key configured — open / local mode
return {"username": "local", "email": "local@localhost"} return {"username": "local", "email": "local@localhost"}
raise HTTPException(401, "unauthorized") 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 ────────────────────────────────────────────────────── # ── Ticket endpoints ──────────────────────────────────────────────────────
@router.get("/api/tickets") @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=[]), status: list[str] = Query(default=[]),
type: list[str] = Query(default=[]), type: list[str] = Query(default=[]),
q: str = Query(default=""), q: str = Query(default=""),
environment_id: int | None = Query(default=None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth), _auth=Depends(require_ticket_auth),
): ):
stmt = select(Ticket).order_by(Ticket.submitted_at.desc()) 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: if status:
stmt = stmt.where(Ticket.status.in_(status)) stmt = stmt.where(Ticket.status.in_(status))
if type: 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") raise HTTPException(400, "effort must be between 1 and 10")
if body.parent_id is not None: if body.parent_id is not None:
await _get_ticket_or_404(db, body.parent_id) 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( t = Ticket(
guid=str(uuid.uuid4()), guid=str(uuid.uuid4()),
user=body.user, user=body.user,
@ -166,6 +290,7 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter:
parent_id=body.parent_id, parent_id=body.parent_id,
effort=body.effort, effort=body.effort,
startup_script=body.startup_script, startup_script=body.startup_script,
environment_id=env_id,
) )
db.add(t) db.add(t)
await db.commit() await db.commit()
@ -215,6 +340,9 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter:
t.effort = body.effort t.effort = body.effort
if "startup_script" in body.model_fields_set: if "startup_script" in body.model_fields_set:
t.startup_script = body.startup_script 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() await db.commit()
return await _ticket_dict(await _get_ticket_or_404(db, ticket_id), db) 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; pointer-events: none;
} }
.search-match { background: #4f8ef733; border-radius: 2px; } .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> </style>
{% endblock %} {% endblock %}
{% block content %} {% 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 --> <!-- Toolbar -->
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:16px; flex-wrap:wrap;"> <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;"> <div style="display:flex; flex-direction:column; gap:6px;">
@ -391,6 +432,31 @@ textarea.desc-input {
</div> </div>
</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 --> <!-- Edit modal -->
<div class="overlay" id="edit-overlay"> <div class="overlay" id="edit-overlay">
<div class="modal" style="min-width:520px" onclick="event.stopPropagation()"> <div class="modal" style="min-width:520px" onclick="event.stopPropagation()">
@ -462,6 +528,8 @@ let expandedParents = new Set();
let currentAttach = null; let currentAttach = null;
let tickets = []; let tickets = [];
let attachments = []; let attachments = [];
let environments = [];
let currentEnvironmentId = null;
const STATUS_COLORS = { const STATUS_COLORS = {
open: 'badge-open', open: 'badge-open',
@ -518,12 +586,104 @@ function fmt_date(iso) {
return d.toLocaleDateString('en-IE', {year:'numeric',month:'short',day:'numeric'}); 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() { async function loadTickets() {
try { try {
const parts = [ const parts = [
...[...activeFilters].map(s => `status=${encodeURIComponent(s)}`), ...[...activeFilters].map(s => `status=${encodeURIComponent(s)}`),
...[...activeTypeFilters].map(t => `type=${encodeURIComponent(t)}`), ...[...activeTypeFilters].map(t => `type=${encodeURIComponent(t)}`),
]; ];
if (currentEnvironmentId !== null) parts.push(`environment_id=${currentEnvironmentId}`);
const url = parts.length ? `/api/tickets?${parts.join('&')}` : '/api/tickets'; const url = parts.length ? `/api/tickets?${parts.join('&')}` : '/api/tickets';
tickets = await api(url); tickets = await api(url);
renderList(); renderList();
@ -718,6 +878,7 @@ async function submitCreate() {
user: document.getElementById('c-user').value, user: document.getElementById('c-user').value,
parent_id: parentVal ? parseInt(parentVal) : null, parent_id: parentVal ? parseInt(parentVal) : null,
effort: effortVal ? parseInt(effortVal) : null, effort: effortVal ? parseInt(effortVal) : null,
environment_id: currentEnvironmentId,
}), }),
}); });
closeCreate(); closeCreate();
@ -968,6 +1129,8 @@ document.addEventListener('keydown', e => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (document.getElementById('att-edit-overlay').classList.contains('active')) closeAttachEdit(); 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('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('edit-overlay').classList.contains('active')) closeEdit();
else if (document.getElementById('create-overlay').classList.contains('active')) closeCreate(); else if (document.getElementById('create-overlay').classList.contains('active')) closeCreate();
else if (searchQuery) { else if (searchQuery) {
@ -995,7 +1158,13 @@ document.getElementById('att-view-overlay').addEventListener('click', e => {
document.getElementById('att-edit-overlay').addEventListener('click', e => { document.getElementById('att-edit-overlay').addEventListener('click', e => {
if (e.target === e.currentTarget) closeAttachEdit(); 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> </script>
{% endblock %} {% endblock %}

View file

@ -91,10 +91,41 @@ def _check(r: httpx.Response) -> dict | list:
return r.json() 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 ────────────────────────────────────────────────────────────── # ── Ticket tools ──────────────────────────────────────────────────────────────
@mcp.tool() @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 List homelab tickets. Results include attachment_count so you know
which tickets have supporting documents without fetching them. 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. Leave empty to list all types.
q: Full-text search query matched against ticket title and q: Full-text search query matched against ticket title and
description (case-insensitive). Leave empty to skip. description (case-insensitive). Leave empty to skip.
environment_id: Filter by environment ID. Pass 0 (default) for all environments.
""" """
with _client() as c: with _client() as c:
params = {} params = {}
@ -115,6 +147,8 @@ def list_tickets(status: str = "", type: str = "", q: str = "") -> list[dict]:
params["type"] = type params["type"] = type
if q: if q:
params["q"] = q params["q"] = q
if environment_id:
params["environment_id"] = environment_id
return _check(c.get("/api/tickets", params=params)) return _check(c.get("/api/tickets", params=params))
@ -140,6 +174,7 @@ def create_ticket(
user: str = "claude", user: str = "claude",
parent_id: int = 0, parent_id: int = 0,
effort: int = 0, effort: int = 0,
environment_id: int = 0,
) -> dict: ) -> dict:
""" """
Create a new homelab ticket. For longer supporting documents 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). user: Who is submitting kevin or claude (default: claude).
parent_id: ID of the parent ticket (omit or pass 0 for no parent). 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). 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 = { body: dict = {
"title": title, "title": title,
@ -166,6 +204,8 @@ def create_ticket(
body["parent_id"] = parent_id body["parent_id"] = parent_id
if effort: if effort:
body["effort"] = effort body["effort"] = effort
if environment_id:
body["environment_id"] = environment_id
with _client() as c: with _client() as c:
ticket = _check(c.post("/api/tickets", json=body)) ticket = _check(c.post("/api/tickets", json=body))
script = _generate_startup_script( script = _generate_startup_script(