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:
parent
2a7686f398
commit
d5f3ce0667
5 changed files with 442 additions and 40 deletions
45
alembic/versions/0002_environments.py
Normal file
45
alembic/versions/0002_environments.py
Normal 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")
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
136
corvid/router.py
136
corvid/router.py
|
|
@ -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 1–16 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 1–10 (omit or pass 0 to leave unset).
|
effort: Estimated effort rating 1–10 (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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue