diff --git a/netbot/cog_tickets.py b/netbot/cog_tickets.py index c5f127a..bc9d0f3 100644 --- a/netbot/cog_tickets.py +++ b/netbot/cog_tickets.py @@ -4,6 +4,7 @@ import logging import datetime as dt +import math import discord from discord import ScheduledEvent, OptionChoice @@ -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): @@ -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"] diff --git a/netbot/formatting.py b/netbot/formatting.py index 4f8dc17..76b90dc 100644 --- a/netbot/formatting.py +++ b/netbot/formatting.py @@ -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 @@ -32,6 +33,8 @@ 'Immediate': '❗', 'EPIC': 'πŸŽ‡', 'Standing': '🚩', + 'Stale': 'πŸ•ΈοΈ', + 'Open': 'πŸ“‚', '?': '❓', } @@ -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( diff --git a/redmine/tickets.py b/redmine/tickets.py index 0e50fd0..21afaf4 100644 --- a/redmine/tickets.py +++ b/redmine/tickets.py @@ -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?