Skip to content

Commit 86694e9

Browse files
author
Paul Philion
committed
ticket-1203: added ticket parent command and supporting integration test case
1 parent eb75a0f commit 86694e9

File tree

5 files changed

+118
-1
lines changed

5 files changed

+118
-1
lines changed

docs/devlog.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Netbot Development Log
22

3+
## 2024-11-26
4+
5+
Starting work on ticket #1203, `/ticket parent`.
6+
7+
Should be simple to add a new sub-command to `cog_tickets.py`.
8+
9+
Using TDD, let's start with addind a test case, `test_parent_command`
10+
11+
Got that test *failing*, then implemented `parent()` method.
12+
13+
Took awhile to get everything working. Biggest issue was tracking down the name of the parameter requred to actually set the parent: `parent_issue_id`
14+
15+
Once that was figure out, everything was working end-to-end and captured in a full integration test.
16+
17+
318
## 2024-11-01
419

520
Looking at due date parsing, after someone raised a question about format.

netbot/cog_tickets.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,7 @@ async def find_event_for_ticket(self, ctx: discord.ApplicationContext, ticket_id
642642
if event.name.startswith(title_prefix):
643643
return event
644644

645+
645646
@staticmethod
646647
def parse_human_date(date_str: str) -> dt.date:
647648
# should the parser be cached?
@@ -650,6 +651,7 @@ def parse_human_date(date_str: str) -> dt.date:
650651
'PREFER_DATES_FROM': 'future',
651652
'REQUIRE_PARTS': ['day', 'month', 'year']})
652653

654+
653655
@ticket.command(name="due", description="Set a due date for the ticket")
654656
@option("date", description="Due date, in a supported format: 2024-11-01, 11/1/24, next week , 2 months, in 5 days")
655657
async def due(self, ctx: discord.ApplicationContext, date:str):
@@ -713,6 +715,42 @@ async def edit_description(self, ctx: discord.ApplicationContext):
713715
await ctx.respond(f"Cannot find ticket for {ctx.channel}")
714716

715717

718+
@ticket.command(name="parent", description="Set a parent ticket for ")
719+
@option("parent_ticket", description="The ID of the parent ticket")
720+
async def parent(self, ctx: discord.ApplicationContext, parent_ticket:int):
721+
# /ticket parent 234 <- Get *this* ticket and set the parent to 234.
722+
723+
# get ticket Id from thread
724+
ticket_id = NetBot.parse_thread_title(ctx.channel.name)
725+
if not ticket_id:
726+
# error - no ticket ID
727+
await ctx.respond("Command only valid in ticket thread. No ticket info found in this thread.")
728+
return
729+
730+
# validate user
731+
user = self.redmine.user_mgr.find_discord_user(ctx.user.name)
732+
if not user:
733+
await ctx.respond(f"ERROR: Discord user without redmine config: {ctx.user.name}. Create with `/scn add`")
734+
return
735+
736+
# check that parent_ticket is valid
737+
parent = self.redmine.ticket_mgr.get(parent_ticket)
738+
if not parent:
739+
await ctx.respond(f"ERROR: Unknow ticket #: {parent_ticket}")
740+
return
741+
742+
# update the ticket
743+
params = {
744+
"parent_issue_id": parent_ticket,
745+
}
746+
updated = self.redmine.ticket_mgr.update(ticket_id, params, user.login)
747+
ticket_link = self.bot.formatter.redmine_link(updated)
748+
parent_link = self.bot.formatter.redmine_link(parent)
749+
await ctx.respond(
750+
f"Updated parent of {ticket_link} -> {parent_link}",
751+
embed=self.bot.formatter.ticket_embed(ctx, updated))
752+
753+
716754
@ticket.command(name="help", description="Display hepl about ticket management")
717755
async def help(self, ctx: discord.ApplicationContext):
718756
await ctx.respond(embed=self.bot.formatter.help_embed(ctx))

netbot/formatting.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,17 @@ def discord_link(self, ctx: discord.ApplicationContext, ticket:Ticket) -> str:
142142
return thread.jump_url
143143

144144

145+
def ticket_link(self, ctx: discord.ApplicationContext, ticket_id:int) -> str:
146+
"""Given a ticket ID, construct a link for the ticket, first looking for related Discord thread"""
147+
# handle none context gracefully
148+
if ctx:
149+
thread = ctx.bot.find_ticket_thread(ticket_id)
150+
if thread:
151+
return thread.jump_url
152+
# didn't find context or ticket
153+
return f"[`#{ticket_id}`]({self.base_url}/issues/{ticket_id})"
154+
155+
145156
def format_tracker(self, tracker:NamedId|None) -> str:
146157
if tracker:
147158
return f"{get_emoji(tracker.name)} {tracker.name}"
@@ -324,6 +335,9 @@ def ticket_embed(self, ctx: discord.ApplicationContext, ticket:Ticket) -> discor
324335
if ticket.watchers:
325336
embed.add_field(name="Collaborators", value=self.format_collaborators(ctx, ticket))
326337

338+
if ticket.parent:
339+
embed.add_field(name="Parent", value=self.ticket_link(ctx, ticket.parent.id))
340+
327341
# list the sub-tickets
328342
if ticket.children:
329343
buff = ""

tests/test_cog_tickets.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,3 +494,53 @@ async def test_due_command(self):
494494
# delete the ticket and confirm
495495
self.redmine.ticket_mgr.remove(ticket.id)
496496
self.assertIsNone(self.redmine.ticket_mgr.get(ticket.id))
497+
498+
499+
async def test_parent_command(self):
500+
parent_ticket = child_ticket = None
501+
try:
502+
# create a parent ticket
503+
# create a child ticket
504+
# invoke the `parent` command
505+
# validate the parent ticket has subticket child.
506+
parent_ticket = self.create_test_ticket()
507+
child_ticket = self.create_test_ticket()
508+
509+
# build the context without ticket. should fail
510+
ctx = self.build_context()
511+
ctx.channel = AsyncMock(discord.TextChannel)
512+
ctx.channel.name = "Invalid Channel Name"
513+
await self.cog.parent(ctx, parent_ticket.id)
514+
self.assertIn("Command only valid in ticket thread.", ctx.respond.call_args.args[0])
515+
516+
# build the context including ticket, use invalid date
517+
ctx2 = self.build_context()
518+
ctx2.channel = AsyncMock(discord.TextChannel)
519+
ctx2.channel.name = f"Ticket #{child_ticket.id}"
520+
ctx2.channel.id = test_utils.randint()
521+
await self.cog.parent(ctx2, parent_ticket.id)
522+
embed = ctx2.respond.call_args.kwargs['embed']
523+
self.assertIn(str(child_ticket.id), embed.title)
524+
# This checks the field in the embed, BUT it's not mocked correctly in the context
525+
# found = False
526+
# for field in embed.fields:
527+
# if field.name == "Parent":
528+
# print(f"---------- {field.value}")
529+
# self.assertTrue(field.value.endswith(parent_ticket.id))
530+
# found = True
531+
# self.assertTrue(found, "Parent field not found in embed")
532+
533+
# validate the ticket
534+
check = self.redmine.ticket_mgr.get(child_ticket.id)
535+
self.assertIsNotNone(check.parent)
536+
self.assertEqual(parent_ticket.id, check.parent.id)
537+
538+
finally:
539+
if child_ticket:
540+
# delete the ticket and confirm
541+
self.redmine.ticket_mgr.remove(child_ticket.id)
542+
self.assertIsNone(self.redmine.ticket_mgr.get(child_ticket.id))
543+
if parent_ticket:
544+
# delete the ticket and confirm
545+
self.redmine.ticket_mgr.remove(parent_ticket.id)
546+
self.assertIsNone(self.redmine.ticket_mgr.get(parent_ticket.id))

tests/test_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ def setUpClass(cls):
236236

237237

238238
def create_test_message(self) -> Message:
239-
subject = f"TEST {self.tag} {unittest.TestCase.id(self)}"
239+
subject = f"TEST {self.tag} {unittest.TestCase.id(self)} {randint()}"
240240
text = f"This is a ticket for {unittest.TestCase.id(self)} with {self.tag}."
241241
message = Message(self.user.mail, subject, f"to-{self.tag}@example.com", f"cc-{self.tag}@example.com")
242242
message.set_note(text)

0 commit comments

Comments
 (0)