Skip to content
Merged
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
89 changes: 89 additions & 0 deletions netbot/cog_tickets.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import logging
import datetime as dt
import math

import discord
from discord import ScheduledEvent, OptionChoice
Expand Down Expand Up @@ -298,6 +299,64 @@ def default_term(ctx: discord.ApplicationContext) -> str:
return CHANNEL_MAPPING[ch_name]


class PrevButton(discord.ui.Button):
def __init__(self):
super().__init__(label="◀ Prev", style=discord.ButtonStyle.secondary)

async def callback(self, interaction: discord.Interaction):
view: StatusView = self.view
view.page = max(0, view.page - 1)
view.refresh_buttons()
embed = view.bot.formatter.status_embed(
view.title, view.buckets, view.page, view.total_pages, view.truncated
)
await interaction.response.edit_message(embed=embed, view=view)


class NextButton(discord.ui.Button):
def __init__(self):
super().__init__(label="Next ▶", style=discord.ButtonStyle.secondary)

async def callback(self, interaction: discord.Interaction):
view: StatusView = self.view
view.page = min(view.total_pages - 1, view.page + 1)
view.refresh_buttons()
embed = view.bot.formatter.status_embed(
view.title, view.buckets, view.page, view.total_pages, view.truncated
)
await interaction.response.edit_message(embed=embed, view=view)


class StatusView(discord.ui.View):
"""Paginating view for the /status digest embed."""

TIMEOUT = 180 # seconds

def __init__(self, bot, title: str, buckets: dict, truncated: bool = False):
super().__init__(timeout=self.TIMEOUT)
self.bot = bot
self.title = title
self.buckets = buckets
self.truncated = truncated
self.page = 0
self.total_pages = max(1, math.ceil(len(buckets["sorted_tickets"]) / 5))

self.prev_btn = PrevButton()
self.next_btn = NextButton()
self.add_item(self.prev_btn)
self.add_item(self.next_btn)
self.refresh_buttons()

def refresh_buttons(self):
self.prev_btn.disabled = (self.page == 0)
self.next_btn.disabled = (self.page >= self.total_pages - 1)

async def on_timeout(self):
self.prev_btn.disabled = True
self.next_btn.disabled = True
self.stop()


class TicketsCog(commands.Cog):
"""encapsulate Discord ticket functions"""
def __init__(self, bot:NetBot):
Expand Down Expand Up @@ -894,6 +953,36 @@ async def recordTime(self, ctx: discord.ApplicationContext, hours: float, progra
await ctx.respond(f"Recorded **{hours} hours** on **{program}** for *{user.discord}*")


@discord.slash_command(name="status", description="Show open ticket digest for this team")
async def status_digest(self, ctx: discord.ApplicationContext):
await ctx.defer() # Redmine call may take up to 5 s

term = default_term(ctx)
team = self.redmine.user_mgr.find(term) if term else None

if team:
tickets = self.redmine.ticket_mgr.tickets_for_team(team)
title = f"{team.name} — Open Tickets"
else:
from redmine.tickets import DEFAULT_SORT
tickets = self.redmine.ticket_mgr.tickets(
status_id="open", sort=DEFAULT_SORT, limit=100
)
title = "All Open Tickets"

truncated = len(tickets) >= 100
if truncated:
log.warning(f"/status: hit 100-ticket cap (channel={ctx.channel.name}, term={term})")

buckets = self.redmine.ticket_mgr.bucket_tickets(tickets)
total_pages = max(1, math.ceil(len(buckets["sorted_tickets"]) / 5))

view = StatusView(self.bot, title, buckets, truncated)
embed = self.bot.formatter.status_embed(title, buckets, 0, total_pages, truncated)

await ctx.respond(embed=embed, view=view)


### Ticket autothreading and notification ###

AUTOTHREAD_TRACKERS = ["Mutual-Aid-Action"]
Expand Down
59 changes: 59 additions & 0 deletions netbot/formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
EMBED_VALUE_LEN = 1024
EMBED_FOOTER_LEN = 2048
EMBED_MAX_LEN = 6000
STATUS_PAGE_SIZE = 5

# Note: Up to 10 embeds per response, 25 fields each

Expand All @@ -32,6 +33,8 @@
'Immediate': '❗',
'EPIC': '🎇',
'Standing': '🚩',
'Stale': '🕸️',
'Open': '📂',
'?': '❓',
}

Expand Down Expand Up @@ -471,6 +474,62 @@ def epics_embed(self, ctx: discord.ApplicationContext, epics: list[Ticket]) -> l
return embeds


def status_embed(
self,
title: str,
buckets: dict,
page: int,
total_pages: int,
truncated: bool = False,
) -> discord.Embed:
"""Build a digest embed: five count cards + one page of the Needs Attention list."""
import datetime as dt

embed = discord.Embed(
title=title,
colour=discord.Color.blurple(),
)

# five inline count cards — headers carry the same emoji each list line gets
embed.add_field(name=f"{get_emoji('Open')} Open", value=str(buckets["open"]), inline=True)
embed.add_field(name=f"{get_emoji('High')} High", value=str(buckets["high"]), inline=True)
embed.add_field(name=f"{get_emoji('Normal')} Normal", value=str(buckets["normal"]), inline=True)
embed.add_field(name=f"{get_emoji('Low')} Low", value=str(buckets["low"]), inline=True)
embed.add_field(name=f"{get_emoji('Stale')} Stale", value=str(buckets["stale"]), inline=True)
# blank field to end the inline row cleanly (Discord renders 3 per row)
embed.add_field(name="​", value="​", inline=True)

# Needs attention list for this page
tickets = buckets["sorted_tickets"]
start = page * STATUS_PAGE_SIZE
page_tickets = tickets[start : start + STATUS_PAGE_SIZE]

cutoff = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=21)

lines = []
for t in page_tickets:
age_days = (dt.datetime.now(dt.timezone.utc) - t.updated_on).days
if t.updated_on <= cutoff:
# stale pre-empts priority, so the emoji must too (mirrors bucket_tickets)
emoji = get_emoji("Stale")
label = f"stale · {age_days}d"
else:
emoji = get_emoji(t.priority.name) if t.priority else get_emoji('?')
label = t.priority.name if t.priority else "—"
subject = t.subject[:60] if t.subject else ""
lines.append(f"{emoji} **#{t.id}** {label} — {subject}")

attention_text = "\n".join(lines) if lines else "_No tickets_"
embed.add_field(name="Needs attention", value=attention_text, inline=False)

footer = f"Page {page + 1}/{total_pages}"
if truncated:
footer += " · Showing first 100 tickets — queue may be larger"
embed.set_footer(text=footer)

return embed


def help_embed(self, _: discord.ApplicationContext) -> discord.Embed:
"""Build an embed panel with help"""
embed = discord.Embed(
Expand Down
32 changes: 32 additions & 0 deletions redmine/tickets.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,38 @@ def tickets(self, **kwargs) -> list[Ticket]:
return []


def bucket_tickets(self, tickets: list) -> dict:
"""Partition open tickets into priority/stale buckets for the status digest.

Stale pre-empts priority: a ticket older than TICKET_MAX_AGE days is counted
only in 'stale', never in high/normal/low. Buckets are mutually exclusive
and sum to open (== len(tickets)).
"""
cutoff = synctime.now() - dt.timedelta(days=TICKET_MAX_AGE)

HIGH_NAMES = {"high", "urgent", "immediate"}

high = normal = low = stale = 0
for t in tickets:
if t.updated_on <= cutoff:
stale += 1
elif t.priority and t.priority.name and t.priority.name.lower() in HIGH_NAMES:
high += 1
elif t.priority and t.priority.name and t.priority.name.lower() == "low":
low += 1
else:
normal += 1

return {
"open": len(tickets),
"high": high,
"normal": normal,
"low": low,
"stale": stale,
"sorted_tickets": list(tickets), # Redmine already returns in DEFAULT_SORT order
}


def search(self, term) -> list[Ticket]:
"""search all text of open tickets for the supplied terms"""
# todo url-encode term?
Expand Down