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

@ -91,21 +91,53 @@ 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.
Args:
status: Filter by status open, ongoing, completed, abandoned.
Leave empty to list all statuses.
type: Filter by type feature, bug, chore, project.
Leave empty to list all types.
q: Full-text search query matched against ticket title and
description (case-insensitive). Leave empty to skip.
status: Filter by status open, ongoing, completed, abandoned.
Leave empty to list all statuses.
type: Filter by type feature, bug, chore, project.
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))
@ -133,13 +167,14 @@ def get_ticket(ticket_id: int) -> dict:
@mcp.tool()
def create_ticket(
title: str,
description: str = "",
status: str = "open",
type: str = "feature",
user: str = "claude",
parent_id: int = 0,
effort: int = 0,
title: str,
description: str = "",
status: str = "open",
type: str = "feature",
user: str = "claude",
parent_id: int = 0,
effort: int = 0,
environment_id: int = 0,
) -> dict:
"""
Create a new homelab ticket. For longer supporting documents
@ -147,13 +182,16 @@ def create_ticket(
everything into description.
Args:
title: Short summary of the work item.
description: Markdown body with context, links, acceptance criteria.
status: open (default), ongoing, completed, abandoned.
type: feature (default), bug, chore, project.
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).
title: Short summary of the work item.
description: Markdown body with context, links, acceptance criteria.
status: open (default), ongoing, completed, abandoned.
type: feature (default), bug, chore, project.
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(