diff --git a/docs/deployment.md b/docs/deployment.md index 6b09b6a..b2d391d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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** diff --git a/nephthys/__main__.py b/nephthys/__main__.py index 268b533..28efbd5 100644 --- a/nephthys/__main__.py +++ b/nephthys/__main__.py @@ -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()) diff --git a/nephthys/tasks/close_stale.py b/nephthys/tasks/close_stale.py index 1d1369f..a74154f 100644 --- a/nephthys/tasks/close_stale.py +++ b/nephthys/tasks/close_stale.py @@ -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( @@ -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)) @@ -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( @@ -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 diff --git a/nephthys/utils/env.py b/nephthys/utils/env.py index c98b50c..0c6de39 100644 --- a/nephthys/utils/env.py +++ b/nephthys/utils/env.py @@ -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: diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 59af15f..bdf920f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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()) } @@ -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()) @@ -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()) } + +