Skip to content
Open
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
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,10 @@ DIFFICULTIES=beginner,easy,medium,hard,very-hard,insane
STATUS=Idea,Todo,In Progress,In Review,Change Requested,Done,Verified,Skipped

# Milestone name for created issues (optional)
MILESTONE="<milestone>"
MILESTONE="<milestone>"

# Project status value considered "awaiting review" (optional)
REVIEW_STATUS=In Review

# Maximum number of challenges shown by the /challenge reviews command (optional)
REVIEW_LIMIT=10
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,14 @@ Environment may be set via `.env`, Docker, or CLI flags. Required core variables
| `STATUS` | No | Comma-separated list of project status values | `Idea,Todo,In Progress,In Review,Done` |
| `MILESTONE` | No | Milestone applied to new issues | `CTF 2025` |
| `FLAG_PREFIX` | No | Prefix for flags before `{}` braces | `ctf` |
| `REVIEW_STATUS` | No | Project status value considered "awaiting review" | `In Review` |
| `REVIEW_LIMIT` | No | Maximum number of challenges shown by `/challenge reviews` | `10` |

## Command Reference

All commands are slash commands. Authorization requires the invoking member to have at least one role ID in `DISCORD_ALLOWED_ROLES` and be in the configured guild.

- `/challenges [status] [page]`: Paginated list (10 per page) filtered by issue state.
- `/challenges [status] [page]`: Paginated list (10 per page) filtered by issue state (all/open/closed) or project board status.
- `/challenge`
- `create`: Create challenge
- `issue [name] [category] [difficulty] [status]`: Create challenge issue, and link it to the current channel
Expand All @@ -130,6 +132,7 @@ All commands are slash commands. Authorization requires the invoking member to h
- `difficulty [difficulty] [issue_number]`: Update challenge difficulty. Issue number may be skipped if run in a channel with a linked challenge issue.
- `category [category] [issue_number]`: Update challenge category. Issue number may be skipped if run in a channel with a linked challenge issue.
- `info [issue_number]`: Show challenge metadata. Issue number may be skipped if run in a channel with a linked challenge issue.
- `reviews [author]`: List challenges with status `REVIEW_STATUS` (default `In Review`), optionally filtered by GitHub assignee username. Shows up to `REVIEW_LIMIT` challenges with links to GitHub and, if linked, the Discord channel.
- `link_channel [issue_number]`: Link current channel to issue
- `clear_channel`: Clear current channel mapping

Expand Down
52 changes: 52 additions & 0 deletions src/cogs/challenge/cog.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Cog for the /challenge command group and all subcommands."""

from typing import Optional
from urllib.parse import quote

from discord import app_commands
from discord.ext import commands
Expand Down Expand Up @@ -65,6 +66,57 @@ async def info(self, interaction: BotInteraction, issue_number: Optional[int] =
ctx.logger.error(f"Failed to fetch issue info: {e}")
await interaction.edit_original_response(content="❌ Failed to fetch challenge info. Please contact an admin.")

@challenge_group.command(name="reviews", description="List challenges awaiting review, optionally filtered by GitHub assignee.")
@app_commands.describe(author="GitHub username of the assignee to filter by (optional)")
async def reviews(self, interaction: BotInteraction, author: Optional[str] = None):
ctx = interaction.client.command_context
if await ctx.deny_if_unauthorized(interaction):
return
await interaction.response.defer(thinking=True)
if await ctx.deny_if_github_disabled(interaction):
return
if not ctx.project_id:
await interaction.edit_original_response(content="Project ID not set or could not be resolved. Command disabled.")
return

try:
challenges = ctx.gh.get_issues_by_status(ctx.project_id, ctx.review_status, assignee=author)
except Exception as e:
ctx.logger.error(f"Failed to fetch {ctx.review_status} challenges: {e}")
await interaction.edit_original_response(content=f"❌ Failed to fetch {ctx.review_status} challenges. Please contact an admin.")
return

total = len(challenges)
project_url = f"https://github.com/orgs/{ctx.project_org}/projects/{ctx.project_number}"
filter_query = f'status:"{ctx.review_status}"' + (f' assignee:{author}' if author else '')
project_url += f"?filterQuery={quote(filter_query)}"

header = f"**{discord_clean(ctx.review_status, max_len=100)} challenges" + (f" for {discord_clean(author, max_len=100)}" if author else "") + ":**"
header += f"\n*See all {discord_clean(ctx.review_status, max_len=100)} challenges on [Github]({project_url})*"

if total == 0:
await interaction.edit_original_response(content=f"{header}\n\nNo challenges are currently {ctx.review_status}.")
return

channel_mapping = Store.get_key("challenges", {}) or {}
issue_to_channel = {issue_num: channel_id for channel_id, issue_num in channel_mapping.items()}

lines = []
for challenge in challenges[:ctx.review_limit]:
category = next((label.split(':', 1)[1].strip() for label in challenge["labels"] if label.lower().startswith('category:')), 'Unknown')
line = f"{discord_clean(challenge['title'], max_len=100)} {discord_clean(category, max_len=100)} | [Github]({challenge['url']})"
channel_id = issue_to_channel.get(challenge["number"])
if channel_id and interaction.guild_id:
line += f" [Discord](https://discord.com/channels/{interaction.guild_id}/{channel_id})"
lines.append(line)

msg = header + "\n\nChallenges:\n" + "\n".join(lines)
msg += f"\n\n*A total of {total} challenges are {ctx.review_status}.*"
if total > ctx.review_limit:
msg += f"\n*Showing the first {ctx.review_limit} challenges.*"

await interaction.edit_original_response(content=msg)

@challenge_group.command(name="link_channel", description="Manually link an issue to this channel")
@app_commands.describe(issue_number="GitHub issue number to link to this channel.")
async def link_channel(self, interaction: BotInteraction, issue_number: int):
Expand Down
58 changes: 35 additions & 23 deletions src/cogs/challenges.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@ class ChallengesCog(commands.Cog):
def __init__(self, bot):
self.bot = bot

async def cog_load(self):
# Extend the static all/open/closed choices with the configured project board statuses.
ctx = self.bot.command_context
base_choices = self.challenges._params["status"].choices
self.challenges._params["status"].choices = base_choices + [app_commands.Choice(name=s, value=s) for s in ctx.statuses]

@app_commands.command(name="challenges", description="List challenges in the GitHub.")
@app_commands.describe(
status="Show all, open (non-finished), or closed (finished) challenges.",
status="Show all, open (non-finished), closed (finished), or filter by project board status.",
page="Page number to display (default: 1)")
@app_commands.choices(
status=[
Expand All @@ -27,53 +33,59 @@ async def challenges(self, interaction: discord.Interaction, status: app_command
"""List challenges from GitHub, optionally filtered by status with pagination."""
await interaction.response.defer(thinking=True)
ctx = self.bot.command_context

if await ctx.deny_if_unauthorized(interaction):
return
if await ctx.deny_if_github_disabled(interaction):
return

if not ctx.gh_repo:
await interaction.edit_original_response(content="GitHub repository not set or could not be resolved. Command disabled.")
return

ctx.logger.info(f"Fetching {status.name} challenges from GitHub repository {ctx.gh_repo.full_name}")
if status.value == "open":
issues = ctx.gh_repo.get_issues(state="open", labels=['Challenge'])
elif status.value == "closed":
issues = ctx.gh_repo.get_issues(state="closed", labels=['Challenge'])
if status.value in ("all", "open", "closed"):
state = "all" if status.value == "all" else status.value
raw_issues = ctx.gh_repo.get_issues(state=state, labels=['Challenge'])
items = [{"title": issue.title, "url": issue.html_url} for issue in raw_issues if "/issues/" in issue.html_url]
else:
issues = ctx.gh_repo.get_issues(state="all", labels=['Challenge'])

issues = (issue for issue in issues if "/issues/" in issue.html_url)
issues = list(issues)

if not issues:
if not ctx.project_id:
await interaction.edit_original_response(content="GitHub project board not configured. Status filtering is disabled.")
return
try:
# Project items are assumed to already be challenges, no extra label filter needed here.
items = ctx.gh.get_issues_by_status(ctx.project_id, status.value)
except Exception as e:
ctx.logger.error(f"Failed to fetch {status.value} challenges: {e}")
await interaction.edit_original_response(content=f"❌ Failed to fetch {status.value} challenges. Please contact an admin.")
return

if not items:
await interaction.edit_original_response(content="No challenges found for this filter.")
return

# Pagination logic
items_per_page = 10
total_pages = (len(issues) + items_per_page - 1) // items_per_page # Ceiling division
total_pages = (len(items) + items_per_page - 1) // items_per_page # Ceiling division

# Ensure page has a valid value
if page is None:
page = 1

# Validate page number
if page < 1:
page = 1
elif page > total_pages:
page = total_pages

# Calculate slice indices
start_idx = (page - 1) * items_per_page
end_idx = start_idx + items_per_page

# Get the issues for the current page
page_issues = issues[start_idx:end_idx]
rows = [f"• [{issue.title}]({issue.html_url})" for issue in page_issues]
page_items = items[start_idx:end_idx]
rows = [f"• [{item['title']}]({item['url']})" for item in page_items]

msg = f"Challenges ({status.name}) - Page {page}/{total_pages}:\n" + "\n".join(rows)
msg += f"\n\n[View all issues]({ctx.gh_repo.html_url}/issues)"
await interaction.edit_original_response(content=msg)
Expand Down
18 changes: 17 additions & 1 deletion src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
DEFAULT_STATUSES = "Idea,Todo,In Progress,In review,Done"
DEFAULT_FLAG_PREFIX = "ctf"
DEFAULT_FLAG_LENGTH = 1000
DEFAULT_REVIEW_STATUS = "In Review"
DEFAULT_REVIEW_LIMIT = 10


@dataclass(frozen=True)
Expand All @@ -28,6 +30,8 @@ class BotConfig:
flag_length: int
verbose: bool
debug: bool
review_status: str
review_limit: int

@property
def github_enabled(self) -> bool:
Expand All @@ -53,7 +57,9 @@ def load_config() -> BotConfig:
flag_prefix=_resolve_value(args.flag_prefix, "FLAG_PREFIX", default=DEFAULT_FLAG_PREFIX),
flag_length=int(_resolve_value(args.flag_length, "FLAG_LENGTH", default=str(DEFAULT_FLAG_LENGTH))),
verbose=_resolve_value(args.verbose, "VERBOSE", default="False").lower() == "true",
debug=_resolve_value(args.debug, "DEBUG", default="False").lower() == "true"
debug=_resolve_value(args.debug, "DEBUG", default="False").lower() == "true",
review_status=_resolve_value(args.review_status, "REVIEW_STATUS", default=DEFAULT_REVIEW_STATUS),
review_limit=_resolve_review_limit(args.review_limit)
)


Expand All @@ -73,6 +79,8 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument("--allowed-roles", type=str, help="Comma-separated list of Discord roles allowed to use restricted commands")
parser.add_argument("--flag-prefix", type=str, help="Prefix for challenge flags before the flag brackets (e.g., ctf for ctf{...})")
parser.add_argument("--flag-length", type=int, help="Length of the generated challenge flags")
parser.add_argument("--review-status", type=str, help='Project status value considered "awaiting review"')
parser.add_argument("--review-limit", type=str, help="Maximum number of challenges shown by the /challenge reviews command")
args, _ = parser.parse_known_args()
return args

Expand All @@ -95,3 +103,11 @@ def _resolve_list(cli_value: str | None, env_name: str, default: str = "", filte
if filter_empty:
return [item for item in items if item]
return items


def _resolve_review_limit(cli_value: str | None) -> int:
raw_value = _resolve_value(cli_value, "REVIEW_LIMIT", default=str(DEFAULT_REVIEW_LIMIT))
try:
return int(raw_value)
except ValueError:
return DEFAULT_REVIEW_LIMIT
93 changes: 93 additions & 0 deletions src/github_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,99 @@ def get_project_node_id(self, org: str, project_number: int, is_org: bool = True
self.logger.error(f"Project not found in response: {data}")
return None

def get_issues_by_status(self, project_id, status_name, assignee=None):
"""Return project items whose Status field matches status_name, optionally filtered by assignee login.
Each result is a dict with number, title, url, labels and assignees."""
query = '''
query($projectId:ID!, $cursor:String) {
node(id: $projectId) {
... on ProjectV2 {
items(first: 100, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
nodes {
content {
... on Issue {
number
title
url
labels(first: 20) { nodes { name } }
assignees(first: 10) { nodes { login } }
}
}
fieldValues(first: 100) {
nodes {
... on ProjectV2ItemFieldSingleSelectValue {
field {
... on ProjectV2SingleSelectField {
name
}
}
name
}
}
}
}
}
}
}
}
'''

results = []
cursor = None
while True:
variables = {"projectId": project_id, "cursor": cursor}
r = requests.post(self.api_url, json={"query": query, "variables": variables}, headers=self.headers)
if not r.ok:
self.logger.error(f"Failed to fetch project items: {r.status_code} {r.text}")
break
try:
data = r.json()
except Exception as e:
self.logger.error(f"Could not decode project items response: {e}, content: {r.text}")
break

node = data.get('data', {}).get('node') or {}
items_data = node.get('items', {}) or {}
items = items_data.get('nodes', []) or []

for item in items:
content = item.get('content') if item else None
if not content:
continue

field_values = item.get('fieldValues', {}).get('nodes', []) if item.get('fieldValues') else []
status = None
for field_value in field_values:
field = field_value.get('field', {}) if field_value else {}
if field.get('name') == 'Status':
status = field_value.get('name')
break
if status != status_name:
continue

assignee_logins = [a.get('login') for a in content.get('assignees', {}).get('nodes', [])]
if assignee and assignee not in assignee_logins:
continue

results.append({
"number": content.get('number'),
"title": content.get('title'),
"url": content.get('url'),
"labels": [label.get('name') for label in content.get('labels', {}).get('nodes', [])],
"assignees": assignee_logins,
})

page_info = items_data.get('pageInfo', {})
if not page_info.get('hasNextPage'):
break
cursor = page_info.get('endCursor')

return results

def get_issue_project_status(self, issue_number, project_id, issue_node_id):
"""Return the project status for the given issue in the given project, or 'Unknown' if not found."""
query = '''
Expand Down
6 changes: 5 additions & 1 deletion src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,18 @@
github_repo_name=services.github_repo_name,
github_enabled=services.github_enabled,
project_id=services.project_id,
project_org=services.project_org,
project_number=services.project_number,
milestone_name=services.milestone_name,
guild_id=guild_id,
allowed_roles=config.allowed_role_ids,
categories=config.categories,
difficulties=config.difficulties,
statuses=config.statuses,
flag_prefix=config.flag_prefix,
flag_length=config.flag_length)
flag_length=config.flag_length,
review_status=config.review_status,
review_limit=config.review_limit)

###################
# Bot configuration
Expand Down
4 changes: 4 additions & 0 deletions src/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class AppServices:
github_repo_name: str
github_enabled: bool
project_id: str | None
project_org: str | None
project_number: str | None
milestone_name: str

def initialize_services(config: BotConfig, logger: Logger) -> AppServices:
Expand Down Expand Up @@ -48,4 +50,6 @@ def initialize_services(config: BotConfig, logger: Logger) -> AppServices:
github_repo_name=github_repo_name,
github_enabled=github_enabled,
project_id=project_id,
project_org=project_org,
project_number=project_number,
milestone_name=config.milestone_name)
Loading
Loading