feat: Initial TicketHub release
- FastAPI backend with SQLite - Projects, Tickets, Comments, Webhooks - Modern web UI (TailwindCSS) - Webhook support for integrations - Docker support
This commit is contained in:
parent
75ab2936cc
commit
9aa13a508f
89
README.md
89
README.md
|
|
@ -1,3 +1,88 @@
|
|||
# tickethub
|
||||
# 🎫 TicketHub
|
||||
|
||||
Lightweight open-source ticket/issue tracking system with webhooks
|
||||
Lightweight open-source ticket/issue tracking system with webhook support.
|
||||
|
||||
## Features
|
||||
|
||||
- **Projects** - Organize tickets by project with unique keys (e.g., PROJ-123)
|
||||
- **Tickets** - Create, update, and track issues with status and priority
|
||||
- **Comments** - Add comments to tickets for collaboration
|
||||
- **Webhooks** - Trigger external systems on ticket events
|
||||
- **Simple** - SQLite database, no complex setup required
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Access at http://localhost:8080
|
||||
|
||||
### Manual
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Projects
|
||||
- `GET /api/projects` - List all projects
|
||||
- `POST /api/projects` - Create project
|
||||
- `GET /api/projects/{id}` - Get project
|
||||
- `DELETE /api/projects/{id}` - Delete project
|
||||
|
||||
### Tickets
|
||||
- `GET /api/tickets` - List tickets (filter by `project_id`, `status`)
|
||||
- `POST /api/tickets` - Create ticket
|
||||
- `GET /api/tickets/{id}` - Get ticket
|
||||
- `GET /api/tickets/key/{key}` - Get ticket by key (e.g., PROJ-123)
|
||||
- `PATCH /api/tickets/{id}` - Update ticket
|
||||
- `DELETE /api/tickets/{id}` - Delete ticket
|
||||
|
||||
### Comments
|
||||
- `GET /api/tickets/{id}/comments` - List comments
|
||||
- `POST /api/tickets/{id}/comments` - Add comment
|
||||
|
||||
### Webhooks
|
||||
- `GET /api/webhooks` - List webhooks
|
||||
- `POST /api/webhooks` - Create webhook
|
||||
- `DELETE /api/webhooks/{id}` - Delete webhook
|
||||
- `PATCH /api/webhooks/{id}/toggle` - Enable/disable webhook
|
||||
|
||||
## Webhook Events
|
||||
|
||||
When configured, TicketHub sends POST requests to your webhook URL:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "ticket.created",
|
||||
"timestamp": "2026-02-18T12:00:00Z",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"key": "PROJ-1",
|
||||
"title": "Issue title",
|
||||
"description": "...",
|
||||
"status": "open",
|
||||
"priority": "medium"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Events: `ticket.created`, `ticket.updated`, `comment.added`
|
||||
|
||||
## Integration with JIRA AI Fixer
|
||||
|
||||
Configure webhook URL in your project pointing to JIRA AI Fixer:
|
||||
|
||||
```
|
||||
https://jira-fixer.example.com/api/webhook/tickethub
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ ./app/
|
||||
|
||||
ENV DATABASE_PATH=/data/tickethub.db
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
"""
|
||||
TicketHub - Lightweight Issue Tracking System
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from contextlib import asynccontextmanager
|
||||
import os
|
||||
|
||||
from app.routers import tickets, projects, webhooks, health
|
||||
from app.services.database import init_db
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
app = FastAPI(
|
||||
title="TicketHub",
|
||||
description="Lightweight open-source ticket/issue tracking system",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# API routes
|
||||
app.include_router(health.router, prefix="/api", tags=["health"])
|
||||
app.include_router(projects.router, prefix="/api/projects", tags=["projects"])
|
||||
app.include_router(tickets.router, prefix="/api/tickets", tags=["tickets"])
|
||||
app.include_router(webhooks.router, prefix="/api/webhooks", tags=["webhooks"])
|
||||
|
||||
# Serve frontend
|
||||
if os.path.exists("/app/static"):
|
||||
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
class TicketStatus(str, Enum):
|
||||
OPEN = "open"
|
||||
IN_PROGRESS = "in_progress"
|
||||
RESOLVED = "resolved"
|
||||
CLOSED = "closed"
|
||||
|
||||
class TicketPriority(str, Enum):
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
CRITICAL = "critical"
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
key: str = Field(..., pattern="^[A-Z]{2,10}$")
|
||||
description: Optional[str] = None
|
||||
webhook_url: Optional[str] = None
|
||||
|
||||
class Project(ProjectCreate):
|
||||
id: int
|
||||
created_at: datetime
|
||||
ticket_count: int = 0
|
||||
|
||||
class TicketCreate(BaseModel):
|
||||
project_id: int
|
||||
title: str
|
||||
description: str
|
||||
priority: TicketPriority = TicketPriority.MEDIUM
|
||||
labels: List[str] = []
|
||||
|
||||
class Ticket(TicketCreate):
|
||||
id: int
|
||||
key: str # e.g., "PROJ-123"
|
||||
status: TicketStatus = TicketStatus.OPEN
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class TicketUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[TicketStatus] = None
|
||||
priority: Optional[TicketPriority] = None
|
||||
labels: Optional[List[str]] = None
|
||||
|
||||
class Comment(BaseModel):
|
||||
id: int
|
||||
ticket_id: int
|
||||
author: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
class CommentCreate(BaseModel):
|
||||
author: str = "system"
|
||||
content: str
|
||||
|
||||
class WebhookConfig(BaseModel):
|
||||
id: int
|
||||
project_id: int
|
||||
url: str
|
||||
events: List[str] = ["ticket.created", "ticket.updated", "comment.added"]
|
||||
active: bool = True
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy", "service": "tickethub"}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
from fastapi import APIRouter, HTTPException
|
||||
from typing import List
|
||||
from app.models import Project, ProjectCreate
|
||||
from app.services.database import get_db
|
||||
import json
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("", response_model=List[Project])
|
||||
async def list_projects():
|
||||
db = await get_db()
|
||||
cursor = await db.execute("""
|
||||
SELECT p.*, COUNT(t.id) as ticket_count
|
||||
FROM projects p
|
||||
LEFT JOIN tickets t ON p.id = t.project_id
|
||||
GROUP BY p.id
|
||||
""")
|
||||
rows = await cursor.fetchall()
|
||||
await db.close()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
@router.post("", response_model=Project)
|
||||
async def create_project(project: ProjectCreate):
|
||||
db = await get_db()
|
||||
try:
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO projects (name, key, description, webhook_url) VALUES (?, ?, ?, ?)",
|
||||
(project.name, project.key.upper(), project.description, project.webhook_url)
|
||||
)
|
||||
await db.commit()
|
||||
project_id = cursor.lastrowid
|
||||
|
||||
cursor = await db.execute("SELECT * FROM projects WHERE id = ?", (project_id,))
|
||||
row = await cursor.fetchone()
|
||||
await db.close()
|
||||
return {**dict(row), "ticket_count": 0}
|
||||
except Exception as e:
|
||||
await db.close()
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.get("/{project_id}", response_model=Project)
|
||||
async def get_project(project_id: int):
|
||||
db = await get_db()
|
||||
cursor = await db.execute("""
|
||||
SELECT p.*, COUNT(t.id) as ticket_count
|
||||
FROM projects p
|
||||
LEFT JOIN tickets t ON p.id = t.project_id
|
||||
WHERE p.id = ?
|
||||
GROUP BY p.id
|
||||
""", (project_id,))
|
||||
row = await cursor.fetchone()
|
||||
await db.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return dict(row)
|
||||
|
||||
@router.delete("/{project_id}")
|
||||
async def delete_project(project_id: int):
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM tickets WHERE project_id = ?", (project_id,))
|
||||
await db.execute("DELETE FROM webhooks WHERE project_id = ?", (project_id,))
|
||||
await db.execute("DELETE FROM projects WHERE id = ?", (project_id,))
|
||||
await db.commit()
|
||||
await db.close()
|
||||
return {"status": "deleted"}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import List, Optional
|
||||
from app.models import Ticket, TicketCreate, TicketUpdate, Comment, CommentCreate, TicketStatus
|
||||
from app.services.database import get_db
|
||||
from app.services.webhook import trigger_webhook
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("", response_model=List[Ticket])
|
||||
async def list_tickets(
|
||||
project_id: Optional[int] = None,
|
||||
status: Optional[TicketStatus] = None,
|
||||
limit: int = Query(default=50, le=100)
|
||||
):
|
||||
db = await get_db()
|
||||
query = "SELECT * FROM tickets WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if project_id:
|
||||
query += " AND project_id = ?"
|
||||
params.append(project_id)
|
||||
if status:
|
||||
query += " AND status = ?"
|
||||
params.append(status.value)
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
cursor = await db.execute(query, params)
|
||||
rows = await cursor.fetchall()
|
||||
await db.close()
|
||||
|
||||
tickets = []
|
||||
for row in rows:
|
||||
ticket = dict(row)
|
||||
ticket["labels"] = json.loads(ticket.get("labels", "[]"))
|
||||
tickets.append(ticket)
|
||||
return tickets
|
||||
|
||||
@router.post("", response_model=Ticket)
|
||||
async def create_ticket(ticket: TicketCreate):
|
||||
db = await get_db()
|
||||
|
||||
# Get project and increment sequence
|
||||
cursor = await db.execute("SELECT * FROM projects WHERE id = ?", (ticket.project_id,))
|
||||
project = await cursor.fetchone()
|
||||
if not project:
|
||||
await db.close()
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
project = dict(project)
|
||||
new_seq = project["ticket_sequence"] + 1
|
||||
ticket_key = f"{project['key']}-{new_seq}"
|
||||
|
||||
await db.execute("UPDATE projects SET ticket_sequence = ? WHERE id = ?", (new_seq, ticket.project_id))
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
cursor = await db.execute(
|
||||
"""INSERT INTO tickets (project_id, key, title, description, priority, labels, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(ticket.project_id, ticket_key, ticket.title, ticket.description,
|
||||
ticket.priority.value, json.dumps(ticket.labels), now, now)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
ticket_id = cursor.lastrowid
|
||||
cursor = await db.execute("SELECT * FROM tickets WHERE id = ?", (ticket_id,))
|
||||
row = await cursor.fetchone()
|
||||
await db.close()
|
||||
|
||||
result = dict(row)
|
||||
result["labels"] = json.loads(result.get("labels", "[]"))
|
||||
|
||||
# Trigger webhook
|
||||
await trigger_webhook(ticket.project_id, "ticket.created", result)
|
||||
|
||||
return result
|
||||
|
||||
@router.get("/{ticket_id}", response_model=Ticket)
|
||||
async def get_ticket(ticket_id: int):
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM tickets WHERE id = ?", (ticket_id,))
|
||||
row = await cursor.fetchone()
|
||||
await db.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||
result = dict(row)
|
||||
result["labels"] = json.loads(result.get("labels", "[]"))
|
||||
return result
|
||||
|
||||
@router.get("/key/{ticket_key}", response_model=Ticket)
|
||||
async def get_ticket_by_key(ticket_key: str):
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM tickets WHERE key = ?", (ticket_key.upper(),))
|
||||
row = await cursor.fetchone()
|
||||
await db.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||
result = dict(row)
|
||||
result["labels"] = json.loads(result.get("labels", "[]"))
|
||||
return result
|
||||
|
||||
@router.patch("/{ticket_id}", response_model=Ticket)
|
||||
async def update_ticket(ticket_id: int, update: TicketUpdate):
|
||||
db = await get_db()
|
||||
|
||||
cursor = await db.execute("SELECT * FROM tickets WHERE id = ?", (ticket_id,))
|
||||
existing = await cursor.fetchone()
|
||||
if not existing:
|
||||
await db.close()
|
||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||
|
||||
existing = dict(existing)
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if update.title is not None:
|
||||
updates.append("title = ?")
|
||||
params.append(update.title)
|
||||
if update.description is not None:
|
||||
updates.append("description = ?")
|
||||
params.append(update.description)
|
||||
if update.status is not None:
|
||||
updates.append("status = ?")
|
||||
params.append(update.status.value)
|
||||
if update.priority is not None:
|
||||
updates.append("priority = ?")
|
||||
params.append(update.priority.value)
|
||||
if update.labels is not None:
|
||||
updates.append("labels = ?")
|
||||
params.append(json.dumps(update.labels))
|
||||
|
||||
if updates:
|
||||
updates.append("updated_at = ?")
|
||||
params.append(datetime.utcnow().isoformat())
|
||||
params.append(ticket_id)
|
||||
|
||||
await db.execute(f"UPDATE tickets SET {', '.join(updates)} WHERE id = ?", params)
|
||||
await db.commit()
|
||||
|
||||
cursor = await db.execute("SELECT * FROM tickets WHERE id = ?", (ticket_id,))
|
||||
row = await cursor.fetchone()
|
||||
await db.close()
|
||||
|
||||
result = dict(row)
|
||||
result["labels"] = json.loads(result.get("labels", "[]"))
|
||||
|
||||
# Trigger webhook
|
||||
await trigger_webhook(existing["project_id"], "ticket.updated", result)
|
||||
|
||||
return result
|
||||
|
||||
@router.delete("/{ticket_id}")
|
||||
async def delete_ticket(ticket_id: int):
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM comments WHERE ticket_id = ?", (ticket_id,))
|
||||
await db.execute("DELETE FROM tickets WHERE id = ?", (ticket_id,))
|
||||
await db.commit()
|
||||
await db.close()
|
||||
return {"status": "deleted"}
|
||||
|
||||
# Comments
|
||||
@router.get("/{ticket_id}/comments", response_model=List[Comment])
|
||||
async def list_comments(ticket_id: int):
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM comments WHERE ticket_id = ? ORDER BY created_at ASC",
|
||||
(ticket_id,)
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
await db.close()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
@router.post("/{ticket_id}/comments", response_model=Comment)
|
||||
async def add_comment(ticket_id: int, comment: CommentCreate):
|
||||
db = await get_db()
|
||||
|
||||
cursor = await db.execute("SELECT project_id FROM tickets WHERE id = ?", (ticket_id,))
|
||||
ticket = await cursor.fetchone()
|
||||
if not ticket:
|
||||
await db.close()
|
||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO comments (ticket_id, author, content) VALUES (?, ?, ?)",
|
||||
(ticket_id, comment.author, comment.content)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
comment_id = cursor.lastrowid
|
||||
cursor = await db.execute("SELECT * FROM comments WHERE id = ?", (comment_id,))
|
||||
row = await cursor.fetchone()
|
||||
await db.close()
|
||||
|
||||
result = dict(row)
|
||||
|
||||
# Trigger webhook
|
||||
await trigger_webhook(ticket["project_id"], "comment.added", {
|
||||
"ticket_id": ticket_id,
|
||||
"comment": result
|
||||
})
|
||||
|
||||
return result
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
from fastapi import APIRouter, HTTPException
|
||||
from typing import List
|
||||
from pydantic import BaseModel
|
||||
from app.services.database import get_db
|
||||
import json
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class WebhookCreate(BaseModel):
|
||||
project_id: int
|
||||
url: str
|
||||
events: List[str] = ["ticket.created", "ticket.updated", "comment.added"]
|
||||
|
||||
class WebhookResponse(BaseModel):
|
||||
id: int
|
||||
project_id: int
|
||||
url: str
|
||||
events: List[str]
|
||||
active: bool
|
||||
|
||||
@router.get("", response_model=List[WebhookResponse])
|
||||
async def list_webhooks(project_id: int = None):
|
||||
db = await get_db()
|
||||
if project_id:
|
||||
cursor = await db.execute("SELECT * FROM webhooks WHERE project_id = ?", (project_id,))
|
||||
else:
|
||||
cursor = await db.execute("SELECT * FROM webhooks")
|
||||
rows = await cursor.fetchall()
|
||||
await db.close()
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
wh = dict(row)
|
||||
wh["events"] = json.loads(wh.get("events", "[]"))
|
||||
wh["active"] = bool(wh.get("active", 1))
|
||||
result.append(wh)
|
||||
return result
|
||||
|
||||
@router.post("", response_model=WebhookResponse)
|
||||
async def create_webhook(webhook: WebhookCreate):
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO webhooks (project_id, url, events) VALUES (?, ?, ?)",
|
||||
(webhook.project_id, webhook.url, json.dumps(webhook.events))
|
||||
)
|
||||
await db.commit()
|
||||
webhook_id = cursor.lastrowid
|
||||
|
||||
cursor = await db.execute("SELECT * FROM webhooks WHERE id = ?", (webhook_id,))
|
||||
row = await cursor.fetchone()
|
||||
await db.close()
|
||||
|
||||
result = dict(row)
|
||||
result["events"] = json.loads(result.get("events", "[]"))
|
||||
result["active"] = bool(result.get("active", 1))
|
||||
return result
|
||||
|
||||
@router.delete("/{webhook_id}")
|
||||
async def delete_webhook(webhook_id: int):
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM webhooks WHERE id = ?", (webhook_id,))
|
||||
await db.commit()
|
||||
await db.close()
|
||||
return {"status": "deleted"}
|
||||
|
||||
@router.patch("/{webhook_id}/toggle")
|
||||
async def toggle_webhook(webhook_id: int):
|
||||
db = await get_db()
|
||||
await db.execute("UPDATE webhooks SET active = NOT active WHERE id = ?", (webhook_id,))
|
||||
await db.commit()
|
||||
|
||||
cursor = await db.execute("SELECT * FROM webhooks WHERE id = ?", (webhook_id,))
|
||||
row = await cursor.fetchone()
|
||||
await db.close()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
|
||||
result = dict(row)
|
||||
result["events"] = json.loads(result.get("events", "[]"))
|
||||
result["active"] = bool(result.get("active", 1))
|
||||
return result
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
"""
|
||||
SQLite database for simplicity. Can be swapped for PostgreSQL.
|
||||
"""
|
||||
import aiosqlite
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
DB_PATH = os.getenv("DATABASE_PATH", "/data/tickethub.db")
|
||||
|
||||
async def get_db():
|
||||
db = await aiosqlite.connect(DB_PATH)
|
||||
db.row_factory = aiosqlite.Row
|
||||
return db
|
||||
|
||||
async def init_db():
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
db = await get_db()
|
||||
|
||||
await db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
webhook_url TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ticket_sequence INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT DEFAULT 'open',
|
||||
priority TEXT DEFAULT 'medium',
|
||||
labels TEXT DEFAULT '[]',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
author TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webhooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
events TEXT DEFAULT '["ticket.created","ticket.updated","comment.added"]',
|
||||
active INTEGER DEFAULT 1,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_project ON tickets(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_ticket ON comments(ticket_id);
|
||||
""")
|
||||
|
||||
await db.commit()
|
||||
await db.close()
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
"""
|
||||
Webhook dispatcher - sends events to configured URLs
|
||||
"""
|
||||
import httpx
|
||||
import json
|
||||
from datetime import datetime
|
||||
from app.services.database import get_db
|
||||
|
||||
async def trigger_webhook(project_id: int, event: str, payload: dict):
|
||||
"""Send webhook to all configured endpoints for this project"""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM webhooks WHERE project_id = ? AND active = 1",
|
||||
(project_id,)
|
||||
)
|
||||
webhooks = await cursor.fetchall()
|
||||
|
||||
# Also check project's default webhook_url
|
||||
cursor = await db.execute("SELECT webhook_url FROM projects WHERE id = ?", (project_id,))
|
||||
project = await cursor.fetchone()
|
||||
await db.close()
|
||||
|
||||
urls = []
|
||||
for wh in webhooks:
|
||||
wh = dict(wh)
|
||||
events = json.loads(wh.get("events", "[]"))
|
||||
if event in events or not events:
|
||||
urls.append(wh["url"])
|
||||
|
||||
if project and project["webhook_url"]:
|
||||
urls.append(project["webhook_url"])
|
||||
|
||||
# Deduplicate
|
||||
urls = list(set(urls))
|
||||
|
||||
webhook_payload = {
|
||||
"event": event,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"data": payload
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
for url in urls:
|
||||
try:
|
||||
await client.post(url, json=webhook_payload)
|
||||
except Exception as e:
|
||||
print(f"Webhook failed for {url}: {e}")
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
fastapi==0.109.0
|
||||
uvicorn==0.27.0
|
||||
aiosqlite==0.19.0
|
||||
httpx==0.26.0
|
||||
pydantic==2.5.3
|
||||
python-multipart==0.0.6
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
version: "3.8"
|
||||
|
||||
services:
|
||||
tickethub:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "8080:8000"
|
||||
volumes:
|
||||
- tickethub_data:/data
|
||||
- ./frontend/static:/app/static:ro
|
||||
environment:
|
||||
- DATABASE_PATH=/data/tickethub.db
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
tickethub_data:
|
||||
|
|
@ -0,0 +1,339 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TicketHub</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
.status-open { background: #dbeafe; color: #1e40af; }
|
||||
.status-in_progress { background: #fef3c7; color: #92400e; }
|
||||
.status-resolved { background: #d1fae5; color: #065f46; }
|
||||
.status-closed { background: #e5e7eb; color: #374151; }
|
||||
.priority-low { border-left: 4px solid #10b981; }
|
||||
.priority-medium { border-left: 4px solid #f59e0b; }
|
||||
.priority-high { border-left: 4px solid #f97316; }
|
||||
.priority-critical { border-left: 4px solid #ef4444; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<div id="app" class="min-h-screen">
|
||||
<!-- Header -->
|
||||
<header class="bg-white shadow-sm border-b">
|
||||
<div class="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-2xl">🎫</span>
|
||||
<h1 class="text-xl font-bold text-gray-900">TicketHub</h1>
|
||||
</div>
|
||||
<button onclick="showProjectModal()" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition">
|
||||
+ New Project
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 py-6">
|
||||
<!-- Projects -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-3">Projects</h2>
|
||||
<div id="projects-list" class="flex gap-3 flex-wrap">
|
||||
<!-- Projects loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tickets -->
|
||||
<div class="bg-white rounded-xl shadow-sm border">
|
||||
<div class="p-4 border-b flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Tickets</h2>
|
||||
<button onclick="showTicketModal()" id="new-ticket-btn" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition hidden">
|
||||
+ New Ticket
|
||||
</button>
|
||||
</div>
|
||||
<div id="tickets-list" class="divide-y">
|
||||
<div class="p-8 text-center text-gray-500">Select a project to view tickets</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Project Modal -->
|
||||
<div id="project-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-xl p-6 w-full max-w-md mx-4">
|
||||
<h3 class="text-lg font-semibold mb-4">New Project</h3>
|
||||
<form id="project-form" onsubmit="createProject(event)">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Project Name</label>
|
||||
<input type="text" name="name" required class="w-full border rounded-lg px-3 py-2" placeholder="My Project">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Key (2-10 uppercase letters)</label>
|
||||
<input type="text" name="key" required pattern="[A-Za-z]{2,10}" class="w-full border rounded-lg px-3 py-2 uppercase" placeholder="PROJ">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Webhook URL (optional)</label>
|
||||
<input type="url" name="webhook_url" class="w-full border rounded-lg px-3 py-2" placeholder="https://...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="button" onclick="hideProjectModal()" class="flex-1 border rounded-lg py-2 hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" class="flex-1 bg-blue-600 text-white rounded-lg py-2 hover:bg-blue-700">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket Modal -->
|
||||
<div id="ticket-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
|
||||
<h3 class="text-lg font-semibold mb-4">New Ticket</h3>
|
||||
<form id="ticket-form" onsubmit="createTicket(event)">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Title</label>
|
||||
<input type="text" name="title" required class="w-full border rounded-lg px-3 py-2" placeholder="Issue title">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea name="description" rows="4" required class="w-full border rounded-lg px-3 py-2" placeholder="Describe the issue..."></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Priority</label>
|
||||
<select name="priority" class="w-full border rounded-lg px-3 py-2">
|
||||
<option value="low">Low</option>
|
||||
<option value="medium" selected>Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="button" onclick="hideTicketModal()" class="flex-1 border rounded-lg py-2 hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" class="flex-1 bg-green-600 text-white rounded-lg py-2 hover:bg-green-700">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket Detail Modal -->
|
||||
<div id="ticket-detail-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div class="p-4 border-b flex items-center justify-between">
|
||||
<h3 id="ticket-detail-key" class="text-lg font-semibold"></h3>
|
||||
<button onclick="hideTicketDetail()" class="text-gray-500 hover:text-gray-700">✕</button>
|
||||
</div>
|
||||
<div class="p-6 overflow-y-auto flex-1">
|
||||
<div id="ticket-detail-content"></div>
|
||||
|
||||
<div class="mt-6 border-t pt-4">
|
||||
<h4 class="font-medium mb-3">Comments</h4>
|
||||
<div id="ticket-comments" class="space-y-3 mb-4"></div>
|
||||
<form onsubmit="addComment(event)" class="flex gap-2">
|
||||
<input type="text" id="comment-input" placeholder="Add a comment..." class="flex-1 border rounded-lg px-3 py-2">
|
||||
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = '/api';
|
||||
let currentProject = null;
|
||||
let currentTicket = null;
|
||||
|
||||
// Load projects on start
|
||||
loadProjects();
|
||||
|
||||
async function loadProjects() {
|
||||
const res = await fetch(`${API}/projects`);
|
||||
const projects = await res.json();
|
||||
|
||||
const container = document.getElementById('projects-list');
|
||||
if (projects.length === 0) {
|
||||
container.innerHTML = '<div class="text-gray-500">No projects yet. Create one to get started!</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = projects.map(p => `
|
||||
<button onclick="selectProject(${p.id})"
|
||||
class="px-4 py-2 rounded-lg border ${currentProject?.id === p.id ? 'bg-blue-100 border-blue-300' : 'bg-white hover:bg-gray-50'} transition">
|
||||
<span class="font-medium">${p.key}</span>
|
||||
<span class="text-gray-500 ml-1">(${p.ticket_count})</span>
|
||||
</button>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function selectProject(id) {
|
||||
const res = await fetch(`${API}/projects/${id}`);
|
||||
currentProject = await res.json();
|
||||
loadProjects();
|
||||
loadTickets();
|
||||
document.getElementById('new-ticket-btn').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
if (!currentProject) return;
|
||||
|
||||
const res = await fetch(`${API}/tickets?project_id=${currentProject.id}`);
|
||||
const tickets = await res.json();
|
||||
|
||||
const container = document.getElementById('tickets-list');
|
||||
if (tickets.length === 0) {
|
||||
container.innerHTML = '<div class="p-8 text-center text-gray-500">No tickets yet. Create one!</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = tickets.map(t => `
|
||||
<div onclick="showTicketDetail(${t.id})" class="p-4 hover:bg-gray-50 cursor-pointer priority-${t.priority}">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-sm font-mono text-gray-500">${t.key}</span>
|
||||
<span class="font-medium ml-2">${t.title}</span>
|
||||
</div>
|
||||
<span class="px-2 py-1 rounded text-xs font-medium status-${t.status}">${t.status.replace('_', ' ')}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1 truncate">${t.description}</p>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function showTicketDetail(id) {
|
||||
const res = await fetch(`${API}/tickets/${id}`);
|
||||
currentTicket = await res.json();
|
||||
|
||||
document.getElementById('ticket-detail-key').textContent = currentTicket.key;
|
||||
document.getElementById('ticket-detail-content').innerHTML = `
|
||||
<h2 class="text-xl font-semibold">${currentTicket.title}</h2>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<span class="px-2 py-1 rounded text-xs font-medium status-${currentTicket.status}">${currentTicket.status.replace('_', ' ')}</span>
|
||||
<span class="px-2 py-1 rounded text-xs font-medium bg-gray-100">${currentTicket.priority}</span>
|
||||
</div>
|
||||
<p class="mt-4 text-gray-700 whitespace-pre-wrap">${currentTicket.description}</p>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<select onchange="updateTicketStatus(this.value)" class="border rounded px-2 py-1 text-sm">
|
||||
<option value="open" ${currentTicket.status === 'open' ? 'selected' : ''}>Open</option>
|
||||
<option value="in_progress" ${currentTicket.status === 'in_progress' ? 'selected' : ''}>In Progress</option>
|
||||
<option value="resolved" ${currentTicket.status === 'resolved' ? 'selected' : ''}>Resolved</option>
|
||||
<option value="closed" ${currentTicket.status === 'closed' ? 'selected' : ''}>Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
|
||||
loadComments();
|
||||
document.getElementById('ticket-detail-modal').classList.remove('hidden');
|
||||
document.getElementById('ticket-detail-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
async function loadComments() {
|
||||
const res = await fetch(`${API}/tickets/${currentTicket.id}/comments`);
|
||||
const comments = await res.json();
|
||||
|
||||
document.getElementById('ticket-comments').innerHTML = comments.length === 0
|
||||
? '<div class="text-gray-500 text-sm">No comments yet</div>'
|
||||
: comments.map(c => `
|
||||
<div class="bg-gray-50 rounded-lg p-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="font-medium">${c.author}</span>
|
||||
<span class="text-gray-500">${new Date(c.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<p class="mt-1">${c.content}</p>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function addComment(e) {
|
||||
e.preventDefault();
|
||||
const input = document.getElementById('comment-input');
|
||||
if (!input.value.trim()) return;
|
||||
|
||||
await fetch(`${API}/tickets/${currentTicket.id}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ author: 'user', content: input.value })
|
||||
});
|
||||
|
||||
input.value = '';
|
||||
loadComments();
|
||||
}
|
||||
|
||||
async function updateTicketStatus(status) {
|
||||
await fetch(`${API}/tickets/${currentTicket.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
loadTickets();
|
||||
}
|
||||
|
||||
function hideTicketDetail() {
|
||||
document.getElementById('ticket-detail-modal').classList.add('hidden');
|
||||
document.getElementById('ticket-detail-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
function showProjectModal() {
|
||||
document.getElementById('project-modal').classList.remove('hidden');
|
||||
document.getElementById('project-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
function hideProjectModal() {
|
||||
document.getElementById('project-modal').classList.add('hidden');
|
||||
document.getElementById('project-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
function showTicketModal() {
|
||||
document.getElementById('ticket-modal').classList.remove('hidden');
|
||||
document.getElementById('ticket-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
function hideTicketModal() {
|
||||
document.getElementById('ticket-modal').classList.add('hidden');
|
||||
document.getElementById('ticket-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
async function createProject(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = {
|
||||
name: form.name.value,
|
||||
key: form.key.value.toUpperCase(),
|
||||
webhook_url: form.webhook_url.value || null
|
||||
};
|
||||
|
||||
await fetch(`${API}/projects`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
form.reset();
|
||||
hideProjectModal();
|
||||
loadProjects();
|
||||
}
|
||||
|
||||
async function createTicket(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = {
|
||||
project_id: currentProject.id,
|
||||
title: form.title.value,
|
||||
description: form.description.value,
|
||||
priority: form.priority.value
|
||||
};
|
||||
|
||||
await fetch(`${API}/tickets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
form.reset();
|
||||
hideTicketModal();
|
||||
loadTickets();
|
||||
loadProjects();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue