Skip to content

Commit

Permalink
Merge pull request #15 from Local-Connectivity-Lab/ticket-486
Browse files Browse the repository at this point in the history
Ticket 486 - Provide better information display for ticket information in Discord
  • Loading branch information
philion authored Mar 11, 2024
2 parents 089946e + 398a1f7 commit b2c51a2
Show file tree
Hide file tree
Showing 8 changed files with 175 additions and 78 deletions.
1 change: 1 addition & 0 deletions cog_scn.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ async def unblock(self, ctx:discord.ApplicationContext, username:str):
log.debug("trying to unblock unknown user '{username}', ignoring")
await ctx.respond(f"Unknown user: {username}")

## FIXME move to DiscordFormatter

async def print_team(self, ctx, team):
msg = f"> **{team.name}**\n"
Expand Down
68 changes: 9 additions & 59 deletions cog_tickets.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,11 @@ async def tickets(self, ctx: discord.ApplicationContext, params: str = ""):
args = params.split()

if len(args) == 0 or args[0] == "me":
await self.print_tickets("My Tickets", self.redmine.my_tickets(user.login), ctx)
await self.bot.formatter.print_tickets("My Tickets", self.redmine.my_tickets(user.login), ctx)
elif len(args) == 1:
query = args[0]
results = self.resolve_query_term(query)
await self.print_tickets(f"Search for '{query}'", results, ctx)
await self.bot.formatter.print_tickets(f"Search for '{query}'", results, ctx)


@commands.slash_command()
Expand All @@ -94,31 +94,31 @@ async def ticket(self, ctx: discord.ApplicationContext, ticket_id:int, action:st
case "show":
ticket = self.redmine.get_ticket(ticket_id)
if ticket:
await ctx.respond(self.format_ticket(ticket)[:2000]) #trunc
await self.bot.formatter.print_ticket(ticket, ctx)
else:
await ctx.respond(f"Ticket {ticket_id} not found.")
case "details":
# FIXME
ticket = self.redmine.get_ticket(ticket_id)
if ticket:
await ctx.respond(self.format_ticket(ticket)[:2000]) #trunc
await self.bot.formatter.print_ticket(ticket, ctx)
else:
await ctx.respond(f"Ticket {ticket_id} not found.")
case "unassign":
self.redmine.unassign_ticket(ticket_id, user.login)
await self.print_ticket(self.redmine.get_ticket(ticket_id), ctx)
await self.bot.formatter.print_ticket(self.redmine.get_ticket(ticket_id), ctx)
case "resolve":
self.redmine.resolve_ticket(ticket_id, user.login)
await self.print_ticket(self.redmine.get_ticket(ticket_id), ctx)
await self.bot.formatter.print_ticket(self.redmine.get_ticket(ticket_id), ctx)
case "progress":
self.redmine.progress_ticket(ticket_id, user.login)
await self.print_ticket(self.redmine.get_ticket(ticket_id), ctx)
await self.bot.formatter.print_ticket(self.redmine.get_ticket(ticket_id), ctx)
#case "note":
# msg = ???
# self.redmine.append_message(ticket_id, user.login, msg)
case "assign":
self.redmine.assign_ticket(ticket_id, user.login)
await self.print_ticket(self.redmine.get_ticket(ticket_id), ctx)
await self.bot.formatter.print_ticket(self.redmine.get_ticket(ticket_id), ctx)
case _:
await ctx.respond("unknown command: {action}")
except Exception as e:
Expand All @@ -141,8 +141,7 @@ async def create_new_ticket(self, ctx: discord.ApplicationContext, title:str):
message.set_note(text)
ticket = self.redmine.create_ticket(user, message)
if ticket:
await ctx.respond(self.format_ticket(ticket)[:2000]) #trunc
# error handling? exception?
await self.bot.formatter.print_ticket(ticket, ctx)
else:
await ctx.respond(f"error creating ticket with title={title}")

Expand Down Expand Up @@ -174,52 +173,3 @@ async def thread_ticket(self, ctx: discord.ApplicationContext, ticket_id:int):
await ctx.respond(f"Created new thread for {ticket.id}: {thread}") # todo add some fancy formatting
else:
await ctx.respond(f"ERROR: Unkown ticket ID: {ticket_id}") # todo add some fancy formatting


### formatting ###

async def print_tickets(self, title, tickets, ctx):
msg = self.format_tickets(title, tickets)

if len(msg) > 2000:
log.warning("message over 2000 chars. truncing.")
msg = msg[:2000]
await ctx.respond(msg)


async def print_ticket(self, ticket, ctx):
msg = self.format_ticket(ticket)

if len(msg) > 2000:
log.warning("message over 2000 chars. truncing.")
msg = msg[:2000]
await ctx.respond(msg)


def format_tickets(self, title, tickets, fields=None, max_len=2000):
if tickets is None:
return "No tickets found."

if fields is None:
fields = ["link","priority","updated_on","assigned_to","subject"]

section = "**" + title + "**\n"
for ticket in tickets:
ticket_line = self.format_ticket(ticket, fields)
if len(section) + len(ticket_line) + 1 < max_len:
# make sure the lenght is less that the max
section += ticket_line + "\n" # append each ticket
else:
break # max_len hit

return section.strip()


def format_ticket(self, ticket, fields=None):
section = ""
if fields is None:
fields = ["link","priority","updated_on","assigned_to","subject"]

for field in fields:
section += str(self.redmine.get_field(ticket, field)) + " " # spacer, one space
return section.strip() # remove trailing whitespace
143 changes: 143 additions & 0 deletions formatting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
#!/usr/bin/env python3

"""Formatting Discord messages"""

import logging

import discord

from tickets import Ticket, TicketManager
from session import RedmineSession
import synctime

log = logging.getLogger(__name__)


MAX_MESSAGE_LEN = 2000


EMOJI = {
'Resolved': '✅',
'Reject': '❌',
'Spam': '❌',
'New': '🟡',
'In Progress': '🔷',
'Low': '🔽',
'Normal': '⏺️',
'High': '🔼',
'Urgent': '⚠️',
'Immediate': '❗',
}

class DiscordFormatter():
"""
Format tickets and user information for Discord
"""
def __init__(self, url: str):
self.base_url = url


async def print_tickets(self, title:str, tickets:list[Ticket], ctx:discord.ApplicationContext):
msg = self.format_tickets(title, tickets)
if len(msg) > MAX_MESSAGE_LEN:
log.warning(f"message over {MAX_MESSAGE_LEN} chars. truncing.")
msg = msg[:MAX_MESSAGE_LEN]
await ctx.respond(msg)


async def print_ticket(self, ticket, ctx):
msg = self.format_ticket_details(ticket)
if len(msg) > MAX_MESSAGE_LEN:
log.warning("message over {MAX_MESSAGE_LEN} chars. truncing.")
msg = msg[:MAX_MESSAGE_LEN]
await ctx.respond(msg)


def format_tickets(self, title:str, tickets:list[Ticket], max_len=MAX_MESSAGE_LEN) -> str:
if tickets is None:
return "No tickets found."

section = "**" + title + "**\n"
for ticket in tickets:
ticket_line = self.format_ticket_row(ticket)
if len(section) + len(ticket_line) + 1 < max_len:
# make sure the lenght is less that the max
section += ticket_line + "\n" # append each ticket
else:
break # max_len hit

return section.strip()


# noting for future: https://docs.python.org/3/library/string.html#string.Template

def format_link(self, ticket:Ticket) -> str: ## to Ticket.link(base_url)?
return f"[`#{ticket.id}`]({self.base_url}/issues/{ticket.id})"


def format_ticket_row(self, ticket:Ticket) -> str:
link = self.format_link(ticket)
# link is mostly hidden, so we can't use the length to format.
# but the length of the ticket id can be used
link_padding = ' ' * (5 - len(str(ticket.id))) # field width = 6
status = EMOJI[ticket.status.name]
priority = EMOJI[ticket.priority.name]
age = synctime.age_str(ticket.updated_on)
assigned = ticket.assigned_to.name if ticket.assigned_to else ""
return f"`{link_padding}`{link}` {status} {priority} {age:<10} {assigned:<18} `{ticket.subject[:60]}"


def format_discord_note(self, note) -> str:
"""Format a note for Discord"""
age = synctime.age_str(note.created_on)
log.info(f"### {note} {age} {note.user}")
return f"> **{note.user}** *{age} ago*\n> {note.notes}"[:MAX_MESSAGE_LEN]


def format_ticket_details(self, ticket:Ticket) -> str:
link = self.format_link(ticket)
# link is mostly hidden, so we can't use the length to format.
# but the length of the ticket id can be used
# layout, based on redmine:
# # Tracker #id
# ## **Subject**
# Added by author (created-ago). Updated (updated ago).
# Status: status Start date: date
# Priority: priority Due date: date|blank
# Assignee: assignee % Done: ...
# Category: category Estimated time: ...
# ### Description
# description text
#link_padding = ' ' * (5 - len(str(ticket.id))) # field width = 6
status = f"{EMOJI[ticket.status.name]} {ticket.status}"
priority = f"{EMOJI[ticket.priority.name]} {ticket.priority}"
created_age = synctime.age_str(ticket.created_on)
updated_age = synctime.age_str(ticket.updated_on)
assigned = ticket.assigned_to.name if ticket.assigned_to else ""

details = f"# {ticket.tracker} {link}\n"
details += f"## {ticket.subject}\n"
details += f"Added by {ticket.author} {created_age} ago. Updated {updated_age} ago.\n"
details += f"**Status:** {status}\n"
details += f"**Priority:** {priority}\n"
details += f"**Assignee:** {assigned}\n"
details += f"**Category:** {ticket.category}\n"
if ticket.to or ticket.cc:
details += f"**To:** {', '.join(ticket.to)} **Cc:** {', '.join(ticket.cc)}\n"

details += f"### Description\n{ticket.description}"
return details


def main():
ticket_manager = TicketManager(RedmineSession.fromenvfile())

# construct the formatter
formatter = DiscordFormatter(ticket_manager.session.url)

tickets = ticket_manager.search("test")
output = formatter.format_tickets("Test Tickets", tickets)
print (output)

if __name__ == '__main__':
main()
15 changes: 4 additions & 11 deletions netbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from dotenv import load_dotenv
from discord.ext import commands

from formatting import DiscordFormatter
from tickets import TicketNote
import synctime
import redmine
Expand All @@ -17,8 +18,6 @@
log = logging.getLogger(__name__)


MAX_MESSAGE_LEN = 2000

class NetbotException(Exception):
"""netbot exception"""

Expand All @@ -33,6 +32,8 @@ def __init__(self, client: redmine.Client):
self.lock = asyncio.Lock()
self.ticket_locks = {}

self.formatter = DiscordFormatter(client.url)

self.redmine = client
#guilds = os.getenv('DISCORD_GUILDS').split(', ')
#if guilds:
Expand Down Expand Up @@ -90,14 +91,6 @@ async def gather_discord_notes(self, thread: discord.Thread, sync_rec:synctime.S
return notes


def format_discord_note(self, note):
"""Format a note for Discord"""
age = synctime.age_str(note.created_on)
log.info(f"### {note} {age} {note.user}")
message = f"> **{note.user}** *{age} ago*\n> {note.notes}"[:MAX_MESSAGE_LEN]
return message


def gather_redmine_notes(self, ticket, sync_rec:synctime.SyncRecord) -> list[TicketNote]:
notes = []
# get the new notes from the redmine ticket
Expand Down Expand Up @@ -162,7 +155,7 @@ async def synchronize_ticket(self, ticket, thread:discord.Thread) -> bool:
for note in redmine_notes:
# Write the note to the discord thread
dirty_flag = True
await thread.send(self.format_discord_note(note))
await thread.send(self.formatter.format_discord_note(note))
log.debug(f"synced {len(redmine_notes)} notes from #{ticket.id} --> {thread}")

# get the new notes from discord
Expand Down
4 changes: 2 additions & 2 deletions test_cog_tickets.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ def setUp(self):


def parse_markdown_link(self, text:str) -> tuple[str, str]:
regex = r"^\[(\d+)\]\((.+)\)"
m = re.match(regex, text)
regex = r"\[`#(\d+)`\]\((.+)\)"
m = re.search(regex, text)
self.assertIsNotNone(m, f"could not find ticket number in response str: {text}")

ticket_id = m.group(1)
Expand Down
3 changes: 2 additions & 1 deletion test_netbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from dotenv import load_dotenv

import netbot
from formatting import MAX_MESSAGE_LEN

import test_utils

Expand Down Expand Up @@ -94,7 +95,7 @@ async def test_sync_ticket_long_message(self):
# assert method send called on mock thread, with the correct values
log.debug(f"### call args: {thread.send.call_args}")
self.assertIn(self.tag, thread.send.call_args.args[0])
self.assertLessEqual(len(thread.send.call_args.args[0]), netbot.MAX_MESSAGE_LEN, "Message sent to Discord is too long")
self.assertLessEqual(len(thread.send.call_args.args[0]), MAX_MESSAGE_LEN, "Message sent to Discord is too long")

# clean up
self.redmine.remove_ticket(ticket.id)
Expand Down
1 change: 1 addition & 0 deletions test_redmine.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def test_blocked_create_ticket(self):
self.assertFalse(self.user_mgr.is_blocked(self.user))


@unittest.skip("takes too long and fills the log with junk")
def test_client_timeout(self):
# construct an invalid client to try to get a timeout
try:
Expand Down
18 changes: 13 additions & 5 deletions tickets.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ class Ticket():
parent: NamedId|None = None
spent_hours: float = 0.0
total_spent_hours: float = 0.0
category: str|None = None
category: NamedId|None = None
assigned_to: NamedId|None = None
custom_fields: list[CustomField]|None = None
journals: list[TicketNote]|None = None
Expand Down Expand Up @@ -119,6 +119,8 @@ def __post_init__(self):
self.custom_fields = [CustomField(**field) for field in self.custom_fields]
if self.journals:
self.journals = [TicketNote(**note) for note in self.journals]
if self.category:
self.category = NamedId(**self.category)

def get_custom_field(self, name: str) -> str | None:
if self.custom_fields:
Expand Down Expand Up @@ -156,16 +158,22 @@ def json_str(self):
def to(self) -> list[str]:
val = self.get_custom_field(TO_CC_FIELD_NAME)
if val:
# string contains to,to//cc,cc
to_str, _ = val.split('//')
if '//' in val:
# string contains to,to//cc,cc
to_str, _ = val.split('//')
else:
to_str = val
return [to.strip() for to in to_str.split(',')]

@property
def cc(self) -> list[str]:
val = self.get_custom_field(TO_CC_FIELD_NAME)
if val:
# string contains to,to//cc,cc
_, cc_str = val.split('//')
if '//' in val:
# string contains to,to//cc,cc
_, cc_str = val.split('//')
else:
cc_str = val
return [to.strip() for to in cc_str.split(',')]

def __str__(self):
Expand Down

0 comments on commit b2c51a2

Please sign in to comment.