Skip to content

Commit

Permalink
Merge pull request #23 from Local-Connectivity-Lab/alert-command
Browse files Browse the repository at this point in the history
Alert command
  • Loading branch information
philion authored Jul 9, 2024
2 parents 4f93109 + 3f0cdca commit 6afba81
Show file tree
Hide file tree
Showing 11 changed files with 196 additions and 40 deletions.
11 changes: 9 additions & 2 deletions cog_scn.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ async def block(self, ctx:discord.ApplicationContext, username:str):

@scn.command(description="unblock specific a email address")
async def unblock(self, ctx:discord.ApplicationContext, username:str):
log.debug(f"unblocking {username}")
log.debug(f"### unblocking {username}")
user = self.redmine.user_mgr.find(username)
if user:
self.redmine.user_mgr.unblock(user)
Expand All @@ -234,7 +234,14 @@ 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

@scn.command(name="force-notify", description="Force ticket notifications")
async def force_notify(self, ctx: discord.ApplicationContext):
log.debug(ctx)
await self.bot.notify_expiring_tickets()


## FIXME move to DiscordFormatter

async def print_team(self, ctx, team):
msg = f"> **{team.name}**\n"
Expand Down
50 changes: 47 additions & 3 deletions cog_tickets.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from model import Message, Ticket
from redmine import Client
from netbot import NetBot


log = logging.getLogger(__name__)
Expand All @@ -24,8 +25,8 @@ def setup(bot):

class TicketsCog(commands.Cog):
"""encapsulate Discord ticket functions"""
def __init__(self, bot):
self.bot = bot
def __init__(self, bot:NetBot):
self.bot:NetBot = bot
self.redmine: Client = bot.redmine

# see https://github.com/Pycord-Development/pycord/blob/master/examples/app_commands/slash_cog_groups.py
Expand Down Expand Up @@ -92,6 +93,24 @@ async def details(self, ctx: discord.ApplicationContext, ticket_id:int):
await ctx.respond(f"Ticket {ticket_id} not found.") # print error



@ticket.command(description="Collaborate on a ticket")
@option("ticket_id", description="ticket ID")
async def collaborate(self, ctx: discord.ApplicationContext, ticket_id:int):
"""Add yourself as a collaborator on a ticket"""
# lookup the user
user = self.redmine.user_mgr.find(ctx.user.name)
if not user:
await ctx.respond(f"User {ctx.user.name} not mapped to redmine. Use `/scn add [redmine-user]` to create the mapping.")
return
ticket = self.redmine.get_ticket(ticket_id)
if ticket:
self.redmine.ticket_mgr.collaborate(ticket_id, user)
await self.bot.formatter.print_ticket(self.redmine.get_ticket(ticket_id), ctx)
else:
await ctx.respond(f"Ticket {ticket_id} not found.") # print error


@ticket.command(description="Unassign a ticket")
@option("ticket_id", description="ticket ID")
async def unassign(self, ctx: discord.ApplicationContext, ticket_id:int):
Expand Down Expand Up @@ -174,7 +193,6 @@ async def create_thread(self, ticket:Ticket, ctx:discord.ApplicationContext):

@ticket.command(name="new", description="Create a new ticket")
@option("title", description="Title of the new SCN ticket")
@option("add_thread", description="Create a Discord thread for the new ticket", default=False)
async def create_new_ticket(self, ctx: discord.ApplicationContext, title:str):
user = self.redmine.user_mgr.find(ctx.user.name)
if not user:
Expand Down Expand Up @@ -208,6 +226,32 @@ async def create_new_ticket(self, ctx: discord.ApplicationContext, title:str):
await ctx.respond(f"Error creating ticket with title={title}")


@ticket.command(name="alert", description="Alert collaborators on a ticket")
@option("ticket_id", description="ID of ticket to alert")
async def alert_ticket(self, ctx: discord.ApplicationContext, ticket_id:int=None):
if not ticket_id:
# check thread for ticket id
ticket_id = self.bot.parse_thread_title(ctx.channel.name)

ticket = self.redmine.get_ticket(ticket_id, include="watchers") # inclde the option watchers/collaborators field
if ticket:
# * notify owner and collaborators of *notable* (not all) status changes of a ticket
# * user @reference for notify
# * notification placed in ticket-thread

# owner and watchers
discord_ids = self.bot.extract_ids_from_ticket(ticket)
thread = self.bot.find_ticket_thread(ticket.id)
if not thread:
await ctx.respond(f"ERROR: No thread for ticket ID: {ticket_id}, assign a fall-back") ## TODO
return
msg = f"Ticket {ticket.id} is about will expire soon."
await thread.send(self.bot.formatter.format_ticket_alert(ticket.id, discord_ids, msg))
await ctx.respond("Alert sent.")
else:
await ctx.respond(f"ERROR: Unkown ticket ID: {ticket_id}") ## TODO format error message


@ticket.command(description="Thread a Redmine ticket in Discord")
@option("ticket_id", description="ID of tick to create thread for")
async def thread(self, ctx: discord.ApplicationContext, ticket_id:int):
Expand Down
13 changes: 9 additions & 4 deletions formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,19 +160,24 @@ def format_ticket_details(self, ticket:Ticket) -> str:
return details


def format_expiration_notification(self, ticket:Ticket):
def format_expiration_notification(self, ticket:Ticket, discord_ids: list[str]):
# format an alert.
# https://discord.com/developers/docs/interactions/message-components#action-rows
# action row with what options?
# :warning:
# ⚠️
# [icon] **Alert** [Ticket x](link) will expire in x hours, as xyz.
return f"ALERT: Expiring ticket: {ticket}" #FIXME
# return self.format_alert(message)
ids_str = ["@" + id for id in discord_ids]
return f"ALERT: Expiring ticket: {self.format_link(ticket)} {' '.join(ids_str)}"


def format_ticket_alert(self, ticket: Ticket, discord_ids: list[str], msg: str):
ids_str = ["@" + id for id in discord_ids]
return f"ALERT #{self.format_link(ticket)} {' '.join(ids_str)}: {msg}"


def main():
ticket_manager = TicketManager(RedmineSession.fromenvfile())
ticket_manager = TicketManager(RedmineSession.fromenvfile(), "1")

# construct the formatter
formatter = DiscordFormatter(ticket_manager.session.url)
Expand Down
6 changes: 5 additions & 1 deletion model.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ class NamedId():

def __str__(self) -> str:
if self.name:
return self.name
return self.name + ":" + str(self.id)
else:
return str(self.id)

Expand Down Expand Up @@ -253,6 +253,7 @@ class Ticket():
custom_fields: list[CustomField]|None = None
journals: list[TicketNote]|None = None
children: list |None = None
watchers: list[NamedId]|None = None


def __post_init__(self):
Expand Down Expand Up @@ -281,6 +282,9 @@ def __post_init__(self):
if self.category and isinstance(self.category, dict):
self.category = NamedId(**self.category)

if self.watchers:
self.watchers = [NamedId(**named) for named in self.watchers]


def __eq__(self, other):
if isinstance(other, self.__class__):
Expand Down
44 changes: 39 additions & 5 deletions netbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ async def sync_thread(self, thread:discord.Thread):
# get the ticket id from the thread name
ticket_id = self.parse_thread_title(thread.name)

ticket = self.redmine.get_ticket(ticket_id, include_journals=True)
ticket = self.redmine.get_ticket(ticket_id, include="journals")
if ticket:
completed = await self.synchronize_ticket(ticket, thread)
# note: synchronize_ticket returns True only when successfully completing a sync
Expand Down Expand Up @@ -339,12 +339,14 @@ async def notify_expiring_tickets(self):
ticket-597
"""
# get list of tickets that will expire (based on rules in ticket_mgr)
for ticket in self.redmine.ticket_mgr.expiring_tickets():
expiring = self.redmine.ticket_mgr.expiring_tickets()
for ticket in expiring:
await self.expiration_notification(ticket)


def expire_expired_tickets(self):
for ticket in self.redmine.ticket_mgr.expired_tickets():
expired = self.redmine.ticket_mgr.expired_tickets()
for ticket in expired:
# notification to discord, or just note provided by expire?
# - for now, add note to ticket with expire info, and allow sync.
self.redmine.ticket_mgr.expire(ticket)
Expand All @@ -353,7 +355,7 @@ def expire_expired_tickets(self):
@commands.slash_command(name="notify", description="Force ticket notifications")
async def force_notify(self, ctx: discord.ApplicationContext):
log.debug(ctx)
await self.check_expired_tickets()
await self.notify_expiring_tickets()


@tasks.loop(hours=24)
Expand All @@ -372,6 +374,38 @@ def lookup_tracker(self, tracker:str) -> NamedId:
return self.trackers.get(tracker, None)


def find_ticket_thread(self, ticket_id:int) -> discord.Thread|None:
"""Search thru thread titles looking for a matching ticket ID"""
# search thru all threads for:
title_prefix = f"Thread #{ticket_id}"
for guild in self.guilds:
for thread in guild.threads:
if thread.name.startswith(title_prefix):
return thread

return None # not found


def extract_ids_from_ticket(self, ticket: Ticket) -> list[str]:
"""Extract the Discord IDs from users interested in a ticket,
using owner and collaborators"""
# owner and watchers
interested: list[NamedId] = []
if ticket.assigned_to is not None:
interested.append(ticket.assigned_to)
interested.extend(ticket.watchers)

discord_ids: list[str] = []
for named in interested:
user = self.redmine.user_mgr.get(named.id)
if user:
discord_ids.append(user.discord_id)
else:
log.info(f"ERROR: user ID {named} not found")

return []


def main():
"""netbot main function"""
log.info(f"loading .env for {__name__}")
Expand All @@ -390,7 +424,7 @@ def main():

def setup_logging():
"""set up logging for netbot"""
logging.basicConfig(level=logging.INFO,
logging.basicConfig(level=logging.DEBUG,
format="{asctime} {levelname:<8s} {name:<16} {message}", style='{')
logging.getLogger("discord.gateway").setLevel(logging.WARNING)
logging.getLogger("discord.http").setLevel(logging.WARNING)
Expand Down
6 changes: 4 additions & 2 deletions redmine.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ def fromenv(cls):


def create_ticket(self, user:User, message:Message) -> Ticket:
# NOTE to self re "projects": TicketManager.create supports a project ID
# Need to find a way to pass it in.
ticket = self.ticket_mgr.create(user, message)
# check user status, reject the ticket if blocked
if self.user_mgr.is_blocked(user):
Expand All @@ -93,8 +95,8 @@ def upload_attachments(self, user:User, attachments):
def get_tickets_by(self, user) -> list[Ticket]:
return self.ticket_mgr.get_tickets_by(user)

def get_ticket(self, ticket_id:int, include_journals:bool = False) -> Ticket:
return self.ticket_mgr.get(ticket_id, include_journals)
def get_ticket(self, ticket_id:int, **params) -> Ticket:
return self.ticket_mgr.get(ticket_id, **params)

#GET /issues.xml?issue_id=1,2
def get_tickets(self, ticket_ids) -> list[Ticket]:
Expand Down
8 changes: 4 additions & 4 deletions session.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,11 @@ def post(self, resource: str, data:str, user_login: str|None = None, files: list
files=files,
timeout=TIMEOUT,
headers=self.get_headers(user_login))
if r.status_code == 201:
#log.debug(f"POST {resource}: {data} - {vars(r)}")
return r.json()
elif r.status_code == 204:

if r.status_code == 204:
return None
elif r.ok:
return r.json()
else:
raise RedmineException(f"POST failed, status=[{r.status_code}] {r.reason}", r.headers['X-Request-Id'])

Expand Down
32 changes: 30 additions & 2 deletions test_cog_tickets.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,33 @@ async def test_new_ticket(self):
# check that the ticket has been removed
self.assertIsNone(self.redmine.get_ticket(int(ticket_id)))

async def test_ticket_unassign(self):
ticket = self.create_test_ticket()

# unassign the ticket
ctx = self.build_context()
await self.cog.unassign(ctx, ticket.id)
response_str = ctx.respond.call_args.args[0]
self.assertIn(str(ticket.id), response_str)

# delete ticket with redmine api, assert
self.redmine.remove_ticket(int(ticket.id))
self.assertIsNone(self.redmine.get_ticket(int(ticket.id)))


async def test_ticket_collaborate(self):
ticket = self.create_test_ticket()

# add a collaborator
ctx = self.build_context()
await self.cog.collaborate(ctx, ticket.id)
response_str = ctx.respond.call_args.args[0]
self.assertIn(str(ticket.id), response_str)

# delete ticket with redmine api, assert
self.redmine.remove_ticket(ticket.id)
self.assertIsNone(self.redmine.get_ticket(int(ticket.id)))

# create thread/sync
async def test_thread_sync(self):
# create a ticket and add a note
Expand Down Expand Up @@ -137,8 +164,9 @@ async def test_query_term(self):
self.assertEqual(ticket.id, result_1[0].id)

# 2. ticket team
result_2 = self.cog.resolve_query_term("ticket-intake")
self.assertEqual(ticket.id, result_2[0].id)
# FIXME not stable, returns oldest intake, not newest
#result_2 = self.cog.resolve_query_term("ticket-intake")
#self.assertEqual(ticket.id, result_2[0].id)

# 3. ticket user
result_3 = self.cog.resolve_query_term(self.user.login)
Expand Down
2 changes: 1 addition & 1 deletion test_netbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ async def test_synchronize_ticket(self):
self.assertIn(body, thread.send.call_args.args[0])

# get notes from redmine, assert tags in most recent
check_ticket = self.redmine.get_ticket(ticket.id, include_journals=True) # get the notes
check_ticket = self.redmine.get_ticket(ticket.id, include="journals") # get the notes
self.assertIsNotNone(check_ticket)
#log.info(f"### ticket: {ticket}")
#self.assertIn(body, ticket.journals[-1].notes) NOT until thread history is working
Expand Down
27 changes: 27 additions & 0 deletions test_tickets.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,34 @@ def test_create_ticket(self):
self.assertIsNone(check3)


def test_ticket_unassign(self):
ticket = self.create_test_ticket()

# unassign the ticket
self.tickets_mgr.unassign_ticket(ticket.id)

check = self.tickets_mgr.get(ticket.id)
self.assertEqual("New", check.status.name)
self.assertEqual("", check.assigned)

# delete ticket with redmine api, assert
self.redmine.remove_ticket(int(ticket.id))
self.assertIsNone(self.redmine.get_ticket(int(ticket.id)))


def test_ticket_collaborate(self):
ticket = self.create_test_ticket()

# unassign the ticket
self.tickets_mgr.collaborate(ticket.id, self.user)

check = self.tickets_mgr.get(ticket.id, include="watchers")
self.assertEqual(self.user.name, check.watchers[0].name)
self.assertEqual(self.user.id, check.watchers[0].id)

# delete ticket with redmine api, assert
self.redmine.remove_ticket(int(ticket.id))
self.assertIsNone(self.redmine.get_ticket(int(ticket.id)))



Expand Down
Loading

0 comments on commit 6afba81

Please sign in to comment.