Add a tickets API (#156)

* Add a tickets API

* Add more fields to JSON response for /api/ticket and /api/tickets

* Prevent returning a massive number of tickets by default

* Add an order to the query

* Document /api/tickets
This commit is contained in:
Mish 2026-02-05 02:53:13 +00:00 committed by GitHub
parent 7a4546a917
commit a1f3ebfaf6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 158 additions and 18 deletions

View file

@ -74,3 +74,45 @@ interface OldestUnansweredTicket {
Note that some fields can be `null` if there are no tickets (or no open/closed/in-progress tickets)
within the time period.
## `/api/tickets`
Returns a big list of tickets and their details! Please provide filters using query parameters
to avoid overloading Nephthys as it tries to provide 1,000s of tickets at once.
Parameters available are:
- `?status=` - filter by ticket status, can be `open`, `closed`, or `in_progress`
- `?since=` or `?after=` - filter for tickets created after a certain date/time, in ISO 8601 format (e.g. `2026-01-01`)
- `?until=` or `?before=` - filter for tickets created before a certain date/time, in ISO 8601 format (e.g. `2026-01-31T12:00:00`)
Returns an array of ticket objects. Ticket objects look like this:
```ts
interface Ticket {
id: number
title: string
description: string
status: "open" | "closed" | "in_progress"
opened_by: User | null
closed_by: User | null
assigned_to: User | null
reopened_by: User | null
tags: Array<string>
created_at: string
message_ts: string
}
interface User {
id: number
slack_id: string
}
```
## `/api/ticket?id=<TICKET_ID>`
Returns a single ticket! See above for details on the ticket object returned.
Required parameter:
- `?id=` - the ID of the ticket to return

View file

@ -2,6 +2,39 @@ from starlette.requests import Request
from starlette.responses import JSONResponse
from nephthys.utils.env import env
from prisma.models import Tag
from prisma.models import Ticket
from prisma.models import User
def user_to_json(user: User | None) -> dict | None:
return {"slack_id": user.slackId, "id": user.id} if user else None
def tag_to_json(tag: Tag | None) -> str:
if tag is None:
raise ValueError("tag is None, did you forget to include the nested relation?")
return tag.name
def ticket_to_json(ticket: Ticket) -> dict:
if ticket.tagsOnTickets is None:
raise ValueError(
"ticket.tagsOnTickets is None, did you forget to include the relation?"
)
return {
"id": ticket.id,
"title": ticket.title,
"description": ticket.description,
"status": ticket.status,
"opened_by": user_to_json(ticket.openedBy),
"closed_by": user_to_json(ticket.closedBy),
"assigned_to": user_to_json(ticket.assignedTo),
"reopened_by": user_to_json(ticket.reopenedBy),
"tags": [tag_to_json(t.tag) for t in ticket.tagsOnTickets],
"created_at": ticket.createdAt.isoformat(),
"message_ts": ticket.msgTs,
}
async def ticket_info(req: Request):
@ -13,26 +46,16 @@ async def ticket_info(req: Request):
return JSONResponse({"error": "invalid_ticket_id"}, status_code=400)
ticket = await env.db.ticket.find_unique(
where={"id": ticket_id},
include={"openedBy": True, "closedBy": True, "assignedTo": True},
include={
"openedBy": True,
"closedBy": True,
"assignedTo": True,
"reopenedBy": True,
"tagsOnTickets": {"include": {"tag": True}},
},
)
if not ticket:
return JSONResponse({"error": "ticket_not_found"}, status_code=404)
return JSONResponse(
{
"title": ticket.title,
"description": ticket.description,
"status": ticket.status,
"opened_by": {"slack_id": ticket.openedBy.slackId}
if ticket.openedBy
else None,
"closed_by": {"slack_id": ticket.closedBy.slackId}
if ticket.closedBy
else None,
"assigned_to": {"slack_id": ticket.assignedTo.slackId}
if ticket.assignedTo
else None,
"created_at": ticket.createdAt.isoformat(),
}
)
return JSONResponse(ticket_to_json(ticket))

73
nephthys/api/tickets.py Normal file
View file

@ -0,0 +1,73 @@
from datetime import datetime
from starlette.requests import Request
from starlette.responses import JSONResponse
from nephthys.api.ticket import ticket_to_json
from nephthys.utils.env import env
from prisma.enums import TicketStatus
from prisma.types import TicketWhereInput
async def tickets_list(req: Request):
filter_status = req.query_params.get("status")
if filter_status:
try:
filter_status = TicketStatus(filter_status.upper())
except ValueError:
return JSONResponse(
{"error": f"Invalid status parameter: {filter_status}"}, status_code=400
)
filter_created_after = req.query_params.get("since") or req.query_params.get(
"after"
)
if filter_created_after:
try:
filter_created_after = datetime.fromisoformat(filter_created_after)
except ValueError:
msg = f"created_after parameter is not a valid ISO datetime: {filter_created_after}"
return JSONResponse({"error": msg}, status_code=400)
filter_created_before = req.query_params.get("until") or req.query_params.get(
"before"
)
if filter_created_before:
try:
filter_created_before = datetime.fromisoformat(filter_created_before)
except ValueError:
msg = f"created_before parameter is not a valid ISO datetime: {filter_created_before}"
return JSONResponse({"error": msg}, status_code=400)
if (
(not filter_status or filter_status == TicketStatus.CLOSED)
and not filter_created_after
and not filter_created_before
):
# Prevent returning a massive number of tickets by default
# If they want them, they should set a big date range explicitly
# (that may also be disallowed in the future if it impacts performance too much)
msg = "Provided filters are too broad"
tip = "Please provide a ?since= or ?until= parameter, or filter by ?status=open or ?status=in_progress"
return JSONResponse({"error": msg, "tip": tip}, status_code=400)
db_filter: TicketWhereInput = {}
if filter_status:
db_filter["status"] = filter_status
if filter_created_after or filter_created_before:
db_filter["createdAt"] = {}
if filter_created_after:
db_filter["createdAt"]["gte"] = filter_created_after
if filter_created_before:
db_filter["createdAt"]["lte"] = filter_created_before
tickets = await env.db.ticket.find_many(
where=db_filter,
include={
"openedBy": True,
"closedBy": True,
"assignedTo": True,
"reopenedBy": True,
"tagsOnTickets": {"include": {"tag": True}},
},
order={"createdAt": "asc"},
)
return JSONResponse([ticket_to_json(t) for t in tickets])

View file

@ -17,6 +17,7 @@ from nephthys.__main__ import main
from nephthys.api.stats import stats
from nephthys.api.stats_v2 import stats_v2
from nephthys.api.ticket import ticket_info
from nephthys.api.tickets import tickets_list
from nephthys.api.user import user_stats
from nephthys.utils.env import env
from nephthys.utils.slack import app as slack_app
@ -68,6 +69,7 @@ app = Starlette(
Route(path="/api/stats", endpoint=stats, methods=["GET"]),
Route(path="/api/stats_v2", endpoint=stats_v2, methods=["GET"]),
Route(path="/api/user", endpoint=user_stats, methods=["GET"]),
Route(path="/api/tickets", endpoint=tickets_list, methods=["GET"]),
Route(path="/api/ticket", endpoint=ticket_info, methods=["GET"]),
Route(path="/health", endpoint=health, methods=["GET"]),
Route(path="/metrics", endpoint=metrics, methods=["GET"]),