Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 19 additions & 19 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# This workflow will install Python dependencies, run tests and lint with a single version of Python
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python

# Updating to use `uv`. Versions pinned are current as of May 4, 2026.

name: Python application

on:
Expand All @@ -14,26 +16,24 @@ permissions:

jobs:
build:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- name: Set up Python 3.13
uses: actions/setup-python@v3
- uses: actions/checkout@v6
- name: "Set up Python"
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"

- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
with:
python-version: "3.13"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
pytest
# Install a specific version of uv.
version: "0.11.8"

- name: Install the project
run: uv sync --locked --all-extras --dev

- name: Run tests
# Run test using uv
run: uv run -m tests
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ run:
lint:
uvx ruff check .

fix:
uvx ruff check . --fix

test: lint
uv run -m tests

Expand Down
1 change: 1 addition & 0 deletions data/roles.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"roles":[{"id":3,"name":"Administrator"},{"id":4,"name":"Volunteer"},{"id":5,"name":"User"}]}
12 changes: 7 additions & 5 deletions netbot/cog_scn.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,11 @@

from discord.commands import option, SlashCommandGroup
from discord.ext import commands
from discord.utils import basic_autocomplete

from redmine.model import Message, User
from redmine.redmine import Client, BLOCKED_TEAM_NAME

from netbot.netbot import NetBot, default_ticket
from netbot.netbot import NetBot

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -169,7 +168,7 @@ def is_admin(self, user: discord.Member) -> bool:

# FIXME rename to "register"?
@scn.command(description="Add a Discord user to redmine")
@option("ticket_id", description="ticket ID", autocomplete=basic_autocomplete(default_ticket))
@option("redmine_login", description="Your redmine user login")
@option("member", description="Discord member collaborating with ticket", optional=True)
async def add(self, ctx:discord.ApplicationContext, redmine_login:str, member:discord.Member=None):
"""add a Discord user to the Redmine ticketing integration"""
Expand All @@ -183,7 +182,10 @@ async def add(self, ctx:discord.ApplicationContext, redmine_login:str, member:di
# check the
id_from_user = user.discord_id
if id_from_user.id > 0:
# a valid
# a valid user
# make sure user has a volunteer role on the SCN project
project_id = 1 # "scn"
self.redmine.user_mgr.assure_project_roles(user, project_id, ["Volunteer", "User"])
await ctx.respond(f"Discord user: {discord_id} is fully configured as redmine user: {user.login}")
else:
# need to update
Expand All @@ -200,7 +202,7 @@ async def add(self, ctx:discord.ApplicationContext, redmine_login:str, member:di
await ctx.send_modal(modal)

# reindex users after changes
self.redmine.user_mgr.reindex_users()
#self.redmine.user_mgr.reindex_users()


@scn.command()
Expand Down
133 changes: 108 additions & 25 deletions netbot/cog_tickets.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
from discord.ext import commands
from discord.enums import InputTextStyle
from discord.ui.item import Item, V
from discord.utils import basic_autocomplete
import discord.utils
from discord.ext import tasks


import dateparser

Expand Down Expand Up @@ -302,11 +304,17 @@ 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
ticket = SlashCommandGroup("ticket", "ticket commands")


@commands.Cog.listener()
async def on_ready(self):
# start the tasks running
self.poll_new_tickets.start()
log.info("Initialized ticket polling")


# figure out what the term refers to
# could be ticket#, team name, user name or search term
def resolve_query_term(self, term) -> list[Ticket]:
Expand Down Expand Up @@ -385,7 +393,7 @@ async def query(self, ctx: discord.ApplicationContext, term:str = ""):


@ticket.command(description="Get ticket details")
@option("ticket_id", description="ticket ID", autocomplete=basic_autocomplete(default_ticket))
@option("ticket_id", description="ticket ID", autocomplete=discord.utils.basic_autocomplete(default_ticket))
async def details(self, ctx: discord.ApplicationContext, ticket_id:int):
"""Update status on a ticket, using: unassign, resolve, progress"""
#log.debug(f"found user mapping for {ctx.user.name}: {user}")
Expand All @@ -397,7 +405,7 @@ async def details(self, ctx: discord.ApplicationContext, ticket_id:int):


@ticket.command(description="Collaborate on a ticket")
@option("ticket_id", description="ticket ID", autocomplete=basic_autocomplete(default_ticket))
@option("ticket_id", description="ticket ID", autocomplete=discord.utils.basic_autocomplete(default_ticket))
@option("member", description="Discord member collaborating with ticket", optional=True)
async def collaborate(self, ctx: discord.ApplicationContext, ticket_id:int, member:discord.Member=None):
"""Add yourself as a collaborator on a ticket"""
Expand All @@ -422,7 +430,7 @@ async def collaborate(self, ctx: discord.ApplicationContext, ticket_id:int, memb


@ticket.command(description="Unassign a ticket")
@option("ticket_id", description="ticket ID", autocomplete=basic_autocomplete(default_ticket))
@option("ticket_id", description="ticket ID", autocomplete=discord.utils.basic_autocomplete(default_ticket))
async def unassign(self, ctx: discord.ApplicationContext, ticket_id:int):
"""Update status on a ticket, using: unassign, resolve, progress"""
# lookup the user
Expand All @@ -440,7 +448,7 @@ async def unassign(self, ctx: discord.ApplicationContext, ticket_id:int):


@ticket.command(description="Resolve a ticket")
@option("ticket_id", description="ticket ID", autocomplete=basic_autocomplete(default_ticket))
@option("ticket_id", description="ticket ID", autocomplete=discord.utils.basic_autocomplete(default_ticket))
async def resolve(self, ctx: discord.ApplicationContext, ticket_id:int):
"""Update status on a ticket, using: unassign, resolve, progress"""
# lookup the user
Expand All @@ -461,7 +469,7 @@ async def resolve(self, ctx: discord.ApplicationContext, ticket_id:int):


@ticket.command(description="Mark a ticket in-progress")
@option("ticket_id", description="ticket ID", autocomplete=basic_autocomplete(default_ticket))
@option("ticket_id", description="ticket ID", autocomplete=discord.utils.basic_autocomplete(default_ticket))
@option("member", description="Discord member taking ownership", optional=True)
async def progress(self, ctx: discord.ApplicationContext, ticket_id:int, member:discord.Member=None):
"""Update status on a ticket, using: progress"""
Expand All @@ -488,7 +496,7 @@ async def progress(self, ctx: discord.ApplicationContext, ticket_id:int, member:


@ticket.command(description="Assign a ticket")
@option("ticket_id", description="ticket ID", autocomplete=basic_autocomplete(default_ticket))
@option("ticket_id", description="ticket ID", autocomplete=discord.utils.basic_autocomplete(default_ticket))
@option("member", description="Discord member taking ownership", optional=True)
async def assign(self, ctx: discord.ApplicationContext, ticket_id:int, member:discord.Member=None):
# lookup the user
Expand Down Expand Up @@ -520,17 +528,16 @@ async def assign(self, ctx: discord.ApplicationContext, ticket_id:int, member:di
# await ctx.respond(f"EDIT #{ticket.id}", view=EditView(self.bot))


async def create_thread(self, ticket:Ticket, ctx:discord.ApplicationContext):
log.info(f"creating a new thread for ticket #{ticket.id} in channel: {ctx.channel.name}")
thread_name = f"Ticket #{ticket.id}: {ticket.subject}"
if isinstance(ctx.channel, discord.Thread):
log.debug(f"creating thread in parent channel {ctx.channel.parent.name}, for {ticket}")
thread = await ctx.channel.parent.create_thread(name=thread_name, type=discord.ChannelType.public_thread)
async def create_thread(self, ticket:Ticket, channel):
if isinstance(channel, discord.Thread):
log.debug(f"creating thread in parent channel {channel.parent.name}, for {ticket}")
thread = await self.create_ticket_thread(ticket, channel.parent)
elif isinstance(channel, discord.TextChannel):
log.debug(f"creating thread in channel {channel.name}, for {ticket}")
thread = await self.create_ticket_thread(ticket, channel)
else:
log.debug(f"creating thread in channel {ctx.channel.name}, for {ticket}")
thread = await ctx.channel.create_thread(name=thread_name, type=discord.ChannelType.public_thread)
# ticket-614: Creating new thread should post the ticket details to the new thread
await thread.send(self.bot.formatter.format_ticket_details(ticket))
log.warning(f"Unrecognized channel type: {type(channel)}, {channel}")

return thread


Expand Down Expand Up @@ -566,7 +573,7 @@ async def create_new_ticket(self, ctx: discord.ApplicationContext, title:str):
log.debug(f"creating ticket in {channel_name} for tracker={tracker}, owner={team}")
ticket = self.redmine.ticket_mgr.create(user, message, tracker_id=tracker.id, assigned_to_id=team.id)
# create new ticket thread
thread = await self.create_thread(ticket, ctx)
thread = await self.create_thread(ticket, ctx.channel)
# use to send notification for team/role
ticket_link = self.bot.formatter.redmine_link(ticket)
alert_msg = f"New ticket created: {ticket_link}"
Expand Down Expand Up @@ -605,7 +612,7 @@ async def notify(self, ctx: discord.ApplicationContext, message:str = ""):


@ticket.command(description="Thread a Redmine ticket in Discord")
@option("ticket_id", description="ID of tick to create thread for")
@option("ticket_id", description="ID of ticket to create thread for")
async def thread(self, ctx: discord.ApplicationContext, ticket_id:int):
ticket = self.redmine.ticket_mgr.get(ticket_id)
if ticket:
Expand All @@ -626,7 +633,7 @@ async def thread(self, ctx: discord.ApplicationContext, ticket_id:int):
# fall thru to create thread and sync

# create the thread...
thread = await self.create_thread(ticket, ctx)
thread = await self.create_thread(ticket, ctx.channel)

# update the discord flag on tickets, add a note with url of thread; thread.jump_url
name = thread.name
Expand All @@ -642,7 +649,7 @@ async def thread(self, ctx: discord.ApplicationContext, ticket_id:int):


@ticket.command(name="tracker", description="Update the tracker of a ticket")
@option("ticket_id", description="ID of ticket to update", autocomplete=basic_autocomplete(default_ticket))
@option("ticket_id", description="ID of ticket to update", autocomplete=discord.utils.basic_autocomplete(default_ticket))
@option("tracker", description="Tracker to assign to ticket", autocomplete=get_trackers)
async def tracker(self, ctx: discord.ApplicationContext, ticket_id:int, tracker:str):
user = self.redmine.user_mgr.find_discord_user(ctx.user.name)
Expand All @@ -665,7 +672,7 @@ async def tracker(self, ctx: discord.ApplicationContext, ticket_id:int, tracker:


@ticket.command(name="status", description="Update the status of a ticket")
@option("ticket_id", description="ID of ticket to update", autocomplete=basic_autocomplete(default_ticket))
@option("ticket_id", description="ID of ticket to update", autocomplete=discord.utils.basic_autocomplete(default_ticket))
@option("status", description="Status to assign to ticket", autocomplete=get_statuses)
async def status(self, ctx: discord.ApplicationContext, ticket_id:int, status:str):
user = self.redmine.user_mgr.find_discord_user(ctx.user.name)
Expand All @@ -688,7 +695,7 @@ async def status(self, ctx: discord.ApplicationContext, ticket_id:int, status:st


@ticket.command(name="priority", description="Update the priority of a ticket")
@option("ticket_id", description="ID of ticket to update", autocomplete=basic_autocomplete(default_ticket))
@option("ticket_id", description="ID of ticket to update", autocomplete=discord.utils.basic_autocomplete(default_ticket))
@option("priority", description="Priority to assign to ticket", autocomplete=get_priorities)
async def priority(self, ctx: discord.ApplicationContext, ticket_id:int, priority:str):
user = self.redmine.user_mgr.find_discord_user(ctx.user.name)
Expand All @@ -715,7 +722,7 @@ async def priority(self, ctx: discord.ApplicationContext, ticket_id:int, priorit


@ticket.command(name="subject", description="Update the subject of a ticket")
@option("ticket_id", description="ID of ticket to update", autocomplete=basic_autocomplete(default_ticket))
@option("ticket_id", description="ID of ticket to update", autocomplete=discord.utils.basic_autocomplete(default_ticket))
@option("subject", description="Updated subject")
async def subject(self, ctx: discord.ApplicationContext, ticket_id:int, subject:str):
user = self.redmine.user_mgr.find_discord_user(ctx.user.name)
Expand Down Expand Up @@ -885,3 +892,79 @@ async def recordTime(self, ctx: discord.ApplicationContext, hours: float, progra
redmine.ticket_mgr.resolve(ticket_id)
program = ctx.bot.redmine.ticket_mgr.get_program_by_id(program_id)
await ctx.respond(f"Recorded **{hours} hours** on **{program}** for *{user.discord}*")


### Ticket autothreading and notification ###

AUTOTHREAD_TRACKERS = ["Mutual-Aid-Action"]
AUTONOTIFY_TRACKERS = ["Mutual-Aid-Action"]


@tasks.loop(minutes=1.0)
async def poll_new_tickets(self):
log.debug("notify_new_tickets. this should be called every minute.")

# check for new tickets
for ticket in self.get_new_tickets():
if ticket.tracker.name in self.AUTOTHREAD_TRACKERS:
log.debug(f"auto-threading ticket {ticket.id} based on tracker: {ticket.tracker}")
await self.sync_ticket(ticket)

if ticket.tracker.name in self.AUTONOTIFY_TRACKERS:
# no need to await the notification
await self.notify_ticket(ticket)


def get_new_tickets(self):
log.debug("Checking for new tickets")
# for now, tickets created in the last 5 minutes
## FIXME - track and manage queries, state in netbot for last query
timestamp = synctime.now() - dt.timedelta(minutes=5)
tickets = self.redmine.ticket_mgr.new_tickets_since(timestamp)
return tickets


### returns true only when a discord thread is created.
async def sync_ticket(self, ticket: Ticket) -> bool:
# first, check if sync currently exists
# possible TODO: cache of ticket id -> thread ids
channel_id = ticket.channel_id
if channel_id == 0:
# find parent channel for thread
parent = self.bot.channel_for_ticket(ticket)
# create thread
thread = await self.create_ticket_thread(ticket, parent)
if thread:
# create sync record
sync_rec = synctime.SyncRecord(ticket.id, thread.id)
self.redmine.ticket_mgr.update_sync_record(sync_rec)
# sync
complete = await self.bot.sync_thread(thread)
return complete
else:
log.error(f"No update. Cannot find channel for {ticket}")
return False
else:
thread = self.bot.channel_for_ticket(ticket)
_ = await self.bot.sync_thread(thread)
return False


async def notify_ticket(self, ticket:Ticket) -> None:
log.info(f"FIXME: Notify ticket: {ticket}")


async def create_ticket_thread(self, ticket: Ticket, parent_channel: discord.TextChannel) -> discord.Thread:
if parent_channel:
log.info(f"creating a new thread for ticket #{ticket.id} in channel: {parent_channel}")
thread_name = f"Ticket #{ticket.id}: {ticket.subject}"

log.debug(f"creating thread in channel {parent_channel.name}, for {ticket}")
thread = await parent_channel.create_thread(name=thread_name, type=discord.ChannelType.public_thread)

# ticket-614: Creating new thread should post the ticket details to the new thread
await thread.send(self.bot.formatter.format_ticket_details(ticket))

return thread
else:
log.warning(f"Empty parent_channel provided for threading ticket {ticket}")
8 changes: 3 additions & 5 deletions netbot/netbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ def append_redmine_note(self, ticket, message: discord.Message) -> None:
self.redmine.ticket_mgr.append_message(ticket.id, user_login=None, note=formatted)


async def synchronize_ticket(self, ticket, thread:discord.Thread) -> bool:
async def synchronize_ticket(self, ticket:Ticket, thread:discord.Thread) -> bool:
"""
Synchronize a ticket to a thread
returns True after a sucessful sync or if there are no changes, false if a sync is in progress.
Expand Down Expand Up @@ -357,8 +357,6 @@ async def sync_thread(self, thread:discord.Thread):
return ticket
else:
raise NetbotException(f"Ticket {ticket.id} is locked for syncronization.")
else:
log.debug(f"no ticket found for {thread.name}")

return None

Expand Down Expand Up @@ -388,8 +386,8 @@ async def sync_all_threads(self):
# ticket is locked.
# skip gracefully
log.debug(str(ex))
except Exception:
log.exception(f"Error syncing {thread}")
except Exception as ex:
log.exception(f"Error syncing {thread}: {ex}")


def channel_for_ticket(self, ticket: Ticket) -> discord.TextChannel:
Expand Down
Loading