From 86694e9d2ae15c626c7fde51db04c93ec19108c3 Mon Sep 17 00:00:00 2001 From: Paul Philion Date: Tue, 26 Nov 2024 12:58:36 -0800 Subject: [PATCH] ticket-1203: added ticket parent command and supporting integration test case --- docs/devlog.md | 15 ++++++++++++ netbot/cog_tickets.py | 38 +++++++++++++++++++++++++++++ netbot/formatting.py | 14 +++++++++++ tests/test_cog_tickets.py | 50 +++++++++++++++++++++++++++++++++++++++ tests/test_utils.py | 2 +- 5 files changed, 118 insertions(+), 1 deletion(-) diff --git a/docs/devlog.md b/docs/devlog.md index 1c6ae18..cacf1d1 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -1,5 +1,20 @@ # Netbot Development Log +## 2024-11-26 + +Starting work on ticket #1203, `/ticket parent`. + +Should be simple to add a new sub-command to `cog_tickets.py`. + +Using TDD, let's start with addind a test case, `test_parent_command` + +Got that test *failing*, then implemented `parent()` method. + +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` + +Once that was figure out, everything was working end-to-end and captured in a full integration test. + + ## 2024-11-01 Looking at due date parsing, after someone raised a question about format. diff --git a/netbot/cog_tickets.py b/netbot/cog_tickets.py index fe6043d..6960174 100644 --- a/netbot/cog_tickets.py +++ b/netbot/cog_tickets.py @@ -642,6 +642,7 @@ async def find_event_for_ticket(self, ctx: discord.ApplicationContext, ticket_id if event.name.startswith(title_prefix): return event + @staticmethod def parse_human_date(date_str: str) -> dt.date: # should the parser be cached? @@ -650,6 +651,7 @@ def parse_human_date(date_str: str) -> dt.date: 'PREFER_DATES_FROM': 'future', 'REQUIRE_PARTS': ['day', 'month', 'year']}) + @ticket.command(name="due", description="Set a due date for the ticket") @option("date", description="Due date, in a supported format: 2024-11-01, 11/1/24, next week , 2 months, in 5 days") async def due(self, ctx: discord.ApplicationContext, date:str): @@ -713,6 +715,42 @@ async def edit_description(self, ctx: discord.ApplicationContext): await ctx.respond(f"Cannot find ticket for {ctx.channel}") + @ticket.command(name="parent", description="Set a parent ticket for ") + @option("parent_ticket", description="The ID of the parent ticket") + async def parent(self, ctx: discord.ApplicationContext, parent_ticket:int): + # /ticket parent 234 <- Get *this* ticket and set the parent to 234. + + # get ticket Id from thread + ticket_id = NetBot.parse_thread_title(ctx.channel.name) + if not ticket_id: + # error - no ticket ID + await ctx.respond("Command only valid in ticket thread. No ticket info found in this thread.") + return + + # validate user + user = self.redmine.user_mgr.find_discord_user(ctx.user.name) + if not user: + await ctx.respond(f"ERROR: Discord user without redmine config: {ctx.user.name}. Create with `/scn add`") + return + + # check that parent_ticket is valid + parent = self.redmine.ticket_mgr.get(parent_ticket) + if not parent: + await ctx.respond(f"ERROR: Unknow ticket #: {parent_ticket}") + return + + # update the ticket + params = { + "parent_issue_id": parent_ticket, + } + updated = self.redmine.ticket_mgr.update(ticket_id, params, user.login) + ticket_link = self.bot.formatter.redmine_link(updated) + parent_link = self.bot.formatter.redmine_link(parent) + await ctx.respond( + f"Updated parent of {ticket_link} -> {parent_link}", + embed=self.bot.formatter.ticket_embed(ctx, updated)) + + @ticket.command(name="help", description="Display hepl about ticket management") async def help(self, ctx: discord.ApplicationContext): await ctx.respond(embed=self.bot.formatter.help_embed(ctx)) diff --git a/netbot/formatting.py b/netbot/formatting.py index f2a8afb..e32ab1b 100644 --- a/netbot/formatting.py +++ b/netbot/formatting.py @@ -142,6 +142,17 @@ def discord_link(self, ctx: discord.ApplicationContext, ticket:Ticket) -> str: return thread.jump_url + def ticket_link(self, ctx: discord.ApplicationContext, ticket_id:int) -> str: + """Given a ticket ID, construct a link for the ticket, first looking for related Discord thread""" + # handle none context gracefully + if ctx: + thread = ctx.bot.find_ticket_thread(ticket_id) + if thread: + return thread.jump_url + # didn't find context or ticket + return f"[`#{ticket_id}`]({self.base_url}/issues/{ticket_id})" + + def format_tracker(self, tracker:NamedId|None) -> str: if tracker: return f"{get_emoji(tracker.name)} {tracker.name}" @@ -324,6 +335,9 @@ def ticket_embed(self, ctx: discord.ApplicationContext, ticket:Ticket) -> discor if ticket.watchers: embed.add_field(name="Collaborators", value=self.format_collaborators(ctx, ticket)) + if ticket.parent: + embed.add_field(name="Parent", value=self.ticket_link(ctx, ticket.parent.id)) + # list the sub-tickets if ticket.children: buff = "" diff --git a/tests/test_cog_tickets.py b/tests/test_cog_tickets.py index 09c8006..fa16af4 100755 --- a/tests/test_cog_tickets.py +++ b/tests/test_cog_tickets.py @@ -494,3 +494,53 @@ async def test_due_command(self): # delete the ticket and confirm self.redmine.ticket_mgr.remove(ticket.id) self.assertIsNone(self.redmine.ticket_mgr.get(ticket.id)) + + + async def test_parent_command(self): + parent_ticket = child_ticket = None + try: + # create a parent ticket + # create a child ticket + # invoke the `parent` command + # validate the parent ticket has subticket child. + parent_ticket = self.create_test_ticket() + child_ticket = self.create_test_ticket() + + # build the context without ticket. should fail + ctx = self.build_context() + ctx.channel = AsyncMock(discord.TextChannel) + ctx.channel.name = "Invalid Channel Name" + await self.cog.parent(ctx, parent_ticket.id) + self.assertIn("Command only valid in ticket thread.", ctx.respond.call_args.args[0]) + + # build the context including ticket, use invalid date + ctx2 = self.build_context() + ctx2.channel = AsyncMock(discord.TextChannel) + ctx2.channel.name = f"Ticket #{child_ticket.id}" + ctx2.channel.id = test_utils.randint() + await self.cog.parent(ctx2, parent_ticket.id) + embed = ctx2.respond.call_args.kwargs['embed'] + self.assertIn(str(child_ticket.id), embed.title) + # This checks the field in the embed, BUT it's not mocked correctly in the context + # found = False + # for field in embed.fields: + # if field.name == "Parent": + # print(f"---------- {field.value}") + # self.assertTrue(field.value.endswith(parent_ticket.id)) + # found = True + # self.assertTrue(found, "Parent field not found in embed") + + # validate the ticket + check = self.redmine.ticket_mgr.get(child_ticket.id) + self.assertIsNotNone(check.parent) + self.assertEqual(parent_ticket.id, check.parent.id) + + finally: + if child_ticket: + # delete the ticket and confirm + self.redmine.ticket_mgr.remove(child_ticket.id) + self.assertIsNone(self.redmine.ticket_mgr.get(child_ticket.id)) + if parent_ticket: + # delete the ticket and confirm + self.redmine.ticket_mgr.remove(parent_ticket.id) + self.assertIsNone(self.redmine.ticket_mgr.get(parent_ticket.id)) diff --git a/tests/test_utils.py b/tests/test_utils.py index 233d5b1..2a1e87f 100755 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -236,7 +236,7 @@ def setUpClass(cls): def create_test_message(self) -> Message: - subject = f"TEST {self.tag} {unittest.TestCase.id(self)}" + subject = f"TEST {self.tag} {unittest.TestCase.id(self)} {randint()}" text = f"This is a ticket for {unittest.TestCase.id(self)} with {self.tag}." message = Message(self.user.mail, subject, f"to-{self.tag}@example.com", f"cc-{self.tag}@example.com") message.set_note(text)