Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ Note: These steps have to be done by a Workspace Admin (otherwise it will be una
LOG_LEVEL_STDERR="WARNING"
# Override the log level for OpenTelemetry output
LOG_LEVEL_OTEL="WARNING"

# Optional: Enable stale ticket auto-close
# Tickets inactive for this many days will be automatically closed
# Leave unset to disable
STALE_TICKET_DAYS="7"
```

4. Don't forget to click **Save All Environment Variables**
Expand Down
17 changes: 10 additions & 7 deletions nephthys/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,16 @@ async def main(_app: Starlette):
timezone="Europe/London",
)

# scheduler.add_job(
# close_stale_tickets,
# "interval",
# hours=1,
# max_instances=1,
# next_run_time=datetime.now(),
# )
from nephthys.tasks.close_stale import close_stale_tickets
from datetime import datetime

scheduler.add_job(
close_stale_tickets,
"interval",
hours=1,
max_instances=1,
next_run_time=datetime.now(),
)
scheduler.start()

delete_msg_task = asyncio.create_task(process_queue())
Expand Down
24 changes: 17 additions & 7 deletions nephthys/tasks/close_stale.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from prisma.enums import TicketStatus


async def get_is_stale(ts: str, max_retries: int = 3) -> bool:
async def get_is_stale(ts: str, stale_ticket_days: int, max_retries: int = 3) -> bool:
for attempt in range(max_retries):
try:
replies = await env.slack_client.conversations_replies(
Expand All @@ -28,7 +28,7 @@ async def get_is_stale(ts: str, max_retries: int = 3) -> bool:
return (
datetime.now(tz=timezone.utc)
- datetime.fromtimestamp(float(last_reply["ts"]), tz=timezone.utc)
) > timedelta(days=3)
) > timedelta(days=stale_ticket_days)
except SlackApiError as e:
if e.response["error"] == "ratelimited":
retry_after = int(e.response.headers.get("Retry-After", 1))
Expand Down Expand Up @@ -82,12 +82,22 @@ async def get_is_stale(ts: str, max_retries: int = 3) -> bool:

async def close_stale_tickets():
"""
Closes tickets that have been open for more than 3 days.
This task is intended to be run periodically.
Closes tickets that have been inactive for more than the configured number of days,
based on the timestamp of the last message in the ticket's Slack thread.

Configure via the STALE_TICKET_DAYS environment variable.
This task is intended to be run periodically (e.g., hourly).
"""

logging.info("Closing stale tickets...")
await send_heartbeat("Closing stale tickets...")
stale_ticket_days = env.stale_ticket_days
if not stale_ticket_days:
logging.info("Stale ticket auto-close is disabled (STALE_TICKET_DAYS not set)")
return

logging.info(f"Closing stale tickets (threshold: {stale_ticket_days} days)...")
await send_heartbeat(
f"Closing stale tickets (threshold: {stale_ticket_days} days)..."
)

try:
tickets = await env.db.ticket.find_many(
Expand All @@ -107,7 +117,7 @@ async def close_stale_tickets():
for ticket in batch:
await asyncio.sleep(1.2) # Rate limiting delay

if await get_is_stale(ticket.msgTs):
if await get_is_stale(ticket.msgTs, stale_ticket_days):
stale += 1
resolver_user = (
ticket.assignedTo if ticket.assignedTo else ticket.openedBy
Expand Down
19 changes: 19 additions & 0 deletions nephthys/utils/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,25 @@ def __init__(self):

self.slack_heartbeat_channel = os.environ.get("SLACK_HEARTBEAT_CHANNEL")

# Stale ticket auto-close: number of days of inactivity before closing
# Set to a positive integer to enable, leave unset to disable
stale_days_str = os.environ.get("STALE_TICKET_DAYS")
if stale_days_str:
try:
stale_days = int(stale_days_str)
self.stale_ticket_days = stale_days if stale_days > 0 else None
if stale_days <= 0:
logging.warning(
f"STALE_TICKET_DAYS must be positive, got {stale_days}. Disabling."
)
except ValueError:
logging.warning(
f"Invalid STALE_TICKET_DAYS value: {stale_days_str}. Disabling."
)
self.stale_ticket_days = None
else:
self.stale_ticket_days = None

unset = [key for key, value in self.__dict__.items() if value == "unset"]

if unset:
Expand Down
18 changes: 10 additions & 8 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ model User {
assignedTickets Ticket[] @relation("AssignedTickets")
reopenedTickets Ticket[] @relation("ReopenedTickets")

tagSubscriptions UserTagSubscription[]
createdCategoryTags CategoryTag[] @relation("CreatedCategoryTags")
helper Boolean @default(false)
tagSubscriptions UserTagSubscription[]
createdCategoryTags CategoryTag[] @relation("CreatedCategoryTags")
helper Boolean @default(false)

createdAt DateTime @default(now())
}
Expand Down Expand Up @@ -75,7 +75,7 @@ model Ticket {
questionTag QuestionTag? @relation("QuestionTagTickets", fields: [questionTagId], references: [id])
questionTagId Int?

categoryTag CategoryTag? @relation("CategoryTagTickets", fields: [categoryTagId], references: [id])
categoryTag CategoryTag? @relation("CategoryTagTickets", fields: [categoryTagId], references: [id])
categoryTagId Int?

createdAt DateTime @default(now())
Expand Down Expand Up @@ -134,12 +134,14 @@ model BotMessage {
}

model CategoryTag {
id Int @id @unique @default(autoincrement())
name String @unique
tickets Ticket[] @relation("CategoryTagTickets")
id Int @id @unique @default(autoincrement())
name String @unique
tickets Ticket[] @relation("CategoryTagTickets")

createdBy User? @relation("CreatedCategoryTags", fields: [createdById], references: [id])
createdBy User? @relation("CreatedCategoryTags", fields: [createdById], references: [id])
createdById Int?

createdAt DateTime @default(now())
}


Loading