From 9092f4335daee1ab6e4f77ea252fa2fc72b760c4 Mon Sep 17 00:00:00 2001 From: gw1108 Date: Tue, 23 Jun 2026 14:32:29 -0700 Subject: [PATCH 1/2] Adding a new netbot /status command to get a summary of the channels high priority tasks. --- netbot/cog_tickets.py | 89 +++++++++++++++++++++++++++++++++++++++++++ netbot/formatting.py | 54 ++++++++++++++++++++++++++ redmine/tickets.py | 32 ++++++++++++++++ 3 files changed, 175 insertions(+) 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..f4a673a 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 @@ -471,6 +472,59 @@ 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 + embed.add_field(name="Open", value=str(buckets["open"]), inline=True) + embed.add_field(name="High", value=str(buckets["high"]), inline=True) + embed.add_field(name="Normal", value=str(buckets["normal"]), inline=True) + embed.add_field(name="Low", value=str(buckets["low"]), inline=True) + embed.add_field(name="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: + label = f"stale · {age_days}d" + else: + label = t.priority.name if t.priority else "—" + subject = t.subject[:60] if t.subject else "" + lines.append(f"**#{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? From deb22c64c72441fec53c88d6cf575f53a3a0fd20 Mon Sep 17 00:00:00 2001 From: gw1108 Date: Tue, 23 Jun 2026 14:53:21 -0700 Subject: [PATCH 2/2] Updating format to also have the emojis. --- netbot/formatting.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/netbot/formatting.py b/netbot/formatting.py index f4a673a..76b90dc 100644 --- a/netbot/formatting.py +++ b/netbot/formatting.py @@ -33,6 +33,8 @@ 'Immediate': '❗', 'EPIC': '🎇', 'Standing': '🚩', + 'Stale': '🕸️', + 'Open': '📂', '?': '❓', } @@ -488,12 +490,12 @@ def status_embed( colour=discord.Color.blurple(), ) - # five inline count cards - embed.add_field(name="Open", value=str(buckets["open"]), inline=True) - embed.add_field(name="High", value=str(buckets["high"]), inline=True) - embed.add_field(name="Normal", value=str(buckets["normal"]), inline=True) - embed.add_field(name="Low", value=str(buckets["low"]), inline=True) - embed.add_field(name="Stale", value=str(buckets["stale"]), inline=True) + # 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) @@ -508,11 +510,14 @@ def status_embed( 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"**#{t.id}** {label} — {subject}") + 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)