Add /add-project command for dynamic project setup
New Features:
- /add-project slash command for adding projects without restart
- Clones git repository with shallow clone (--depth 1)
- Updates config.yaml atomically with rollback on failure
- Live config reload (no bot restart needed)
- Administrator permission required
- Comprehensive validation and error handling
Implementation:
- config.py: add_project() and save() methods
- commands.py: add_project command (193 lines)
- 12 new tests covering all scenarios
- Full documentation in COMMANDS_USAGE.md
Test Results: 30/30 passing (100%)
Usage:
/add-project
project_name: my-project
git_url: https://git.manticorum.com/cal/my-project.git
model: sonnet
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3ccfbe5f99
commit
48c93adade
33
ENHANCEMENT_ADD_PROJECT.md
Normal file
33
ENHANCEMENT_ADD_PROJECT.md
Normal file
@ -0,0 +1,33 @@
|
||||
# Enhancement: /add-project Slash Command Implementation
|
||||
|
||||
**Status**: ✅ Complete
|
||||
**Date**: 2026-02-13
|
||||
**Deployed to**: discord-coordinator (10.10.0.230)
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented /add-project slash command for dynamic project configuration without manual config editing or bot restarts. Command allows administrators to add new project configurations by providing a git repository URL and project details.
|
||||
|
||||
## Test Results
|
||||
|
||||
All 30 tests passing (18 existing + 12 new):
|
||||
- test_add_project_success
|
||||
- test_add_project_duplicate_channel
|
||||
- test_add_project_invalid_name
|
||||
- test_add_project_directory_exists
|
||||
- test_add_project_git_clone_failure
|
||||
- test_add_project_git_timeout
|
||||
- test_add_project_not_git_repo
|
||||
- test_add_project_config_rollback
|
||||
- test_add_project_permission_check
|
||||
- test_add_project_error_handler
|
||||
- test_add_project_with_target_channel
|
||||
- test_config_structure
|
||||
|
||||
## Deployment Instructions
|
||||
|
||||
Bot will automatically register the new command on next startup.
|
||||
|
||||
## Implementation Complete
|
||||
|
||||
All code changes deployed, tested, and documented.
|
||||
@ -20,6 +20,11 @@ from discord.ext import commands
|
||||
|
||||
from claude_coordinator.session_manager import SessionManager
|
||||
from claude_coordinator.config import Config
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -300,6 +305,199 @@ class ClaudeCommands(commands.Cog):
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
@app_commands.command(
|
||||
name="add-project",
|
||||
description="Add a new project configuration dynamically (admin only)"
|
||||
)
|
||||
@app_commands.describe(
|
||||
project_name="Short name for the project (e.g., 'major-domo')",
|
||||
git_url="Git repository URL (e.g., 'https://git.manticorum.com/cal/my-project.git')",
|
||||
channel="Channel to map (defaults to current channel)",
|
||||
model="Claude model to use (default: sonnet)"
|
||||
)
|
||||
@app_commands.choices(model=[
|
||||
app_commands.Choice(name="Sonnet", value="sonnet"),
|
||||
app_commands.Choice(name="Opus", value="opus"),
|
||||
app_commands.Choice(name="Haiku", value="haiku")
|
||||
])
|
||||
@app_commands.checks.has_permissions(administrator=True)
|
||||
async def add_project_command(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
project_name: str,
|
||||
git_url: str,
|
||||
channel: Optional[discord.TextChannel] = None,
|
||||
model: str = "sonnet"
|
||||
):
|
||||
"""Add a new project configuration dynamically.
|
||||
|
||||
Clones the specified Git repository and adds it to bot configuration,
|
||||
allowing Claude coordination in the specified channel without restart.
|
||||
Requires administrator permission.
|
||||
|
||||
Args:
|
||||
interaction: Discord interaction object.
|
||||
project_name: Short name for the project (alphanumeric, hyphens, underscores).
|
||||
git_url: Git repository URL to clone.
|
||||
channel: Optional channel to map (defaults to current channel).
|
||||
model: Claude model to use (sonnet, opus, haiku).
|
||||
"""
|
||||
# 1. Validate inputs
|
||||
channel = channel or interaction.channel
|
||||
channel_id = str(channel.id)
|
||||
|
||||
# Check if channel already configured
|
||||
if self.config.get_project_by_channel(channel_id):
|
||||
await interaction.response.send_message(
|
||||
f"❌ Channel {channel.mention} is already configured!",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Validate project name (alphanumeric, hyphens, underscores)
|
||||
if not re.match(r'^[a-z0-9_-]+$', project_name):
|
||||
await interaction.response.send_message(
|
||||
"❌ Project name must be lowercase alphanumeric with hyphens/underscores only",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# 2. Defer response (this might take a while)
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
# 3. Clone repository
|
||||
project_dir = f"/opt/projects/{project_name}"
|
||||
|
||||
if os.path.exists(project_dir):
|
||||
await interaction.followup.send(
|
||||
f"❌ Directory already exists: `{project_dir}`"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Clone with timeout and shallow clone
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"git", "clone", "--depth", "1", git_url, project_dir,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=120.0 # 2 minutes
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
await interaction.followup.send(
|
||||
"❌ Git clone timed out (>2 minutes)"
|
||||
)
|
||||
return
|
||||
|
||||
if process.returncode != 0:
|
||||
error_msg = stderr.decode('utf-8', errors='replace')[:500]
|
||||
await interaction.followup.send(
|
||||
f"❌ Git clone failed:\\n```\\n{error_msg}\\n```"
|
||||
)
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error during git clone: {e}")
|
||||
await interaction.followup.send(
|
||||
f"❌ Git clone failed: {str(e)}"
|
||||
)
|
||||
return
|
||||
|
||||
# 4. Validate cloned repository
|
||||
if not os.path.exists(os.path.join(project_dir, '.git')):
|
||||
# Clean up
|
||||
try:
|
||||
shutil.rmtree(project_dir)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clean up invalid clone: {e}")
|
||||
|
||||
await interaction.followup.send(
|
||||
"❌ Cloned directory doesn't appear to be a git repository"
|
||||
)
|
||||
return
|
||||
|
||||
# 5. Add to config.yaml
|
||||
new_project = {
|
||||
'name': project_name,
|
||||
'channel_id': channel_id,
|
||||
'project_dir': project_dir,
|
||||
'allowed_tools': ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep'],
|
||||
'model': model,
|
||||
'system_prompt': None
|
||||
}
|
||||
|
||||
try:
|
||||
# Update config atomically
|
||||
self.config.add_project(new_project)
|
||||
self.config.save() # Write to disk
|
||||
|
||||
# 6. Reload config (no restart needed!)
|
||||
self.config.load()
|
||||
|
||||
logger.info(
|
||||
f"Project '{project_name}' added by user {interaction.user.id} "
|
||||
f"for channel {channel_id}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error adding project to config: {e}")
|
||||
|
||||
# Rollback: delete cloned directory
|
||||
try:
|
||||
shutil.rmtree(project_dir)
|
||||
logger.info(f"Rolled back: deleted {project_dir}")
|
||||
except Exception as cleanup_error:
|
||||
logger.error(f"Failed to clean up after config error: {cleanup_error}")
|
||||
|
||||
await interaction.followup.send(
|
||||
f"❌ Failed to update configuration: {str(e)}\\n\\nRolled back changes."
|
||||
)
|
||||
return
|
||||
|
||||
# 7. Success message
|
||||
embed = discord.Embed(
|
||||
title="✅ Project Added Successfully",
|
||||
color=discord.Color.green()
|
||||
)
|
||||
embed.add_field(name="Project", value=project_name, inline=True)
|
||||
embed.add_field(name="Channel", value=channel.mention, inline=True)
|
||||
embed.add_field(name="Model", value=model, inline=True)
|
||||
embed.add_field(name="Directory", value=f"`{project_dir}`", inline=False)
|
||||
embed.add_field(name="Repository", value=git_url, inline=False)
|
||||
embed.set_footer(text="You can now @mention the bot in this channel!")
|
||||
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
@add_project_command.error
|
||||
async def add_project_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):
|
||||
"""Handle errors for add-project command."""
|
||||
if isinstance(error, app_commands.MissingPermissions):
|
||||
await interaction.response.send_message(
|
||||
"❌ You need **Administrator** permission to use this command.",
|
||||
ephemeral=True
|
||||
)
|
||||
else:
|
||||
logger.exception(f"Unhandled error in add-project command: {error}")
|
||||
|
||||
# Try to send error message
|
||||
if interaction.response.is_done():
|
||||
await interaction.followup.send(
|
||||
f"❌ An error occurred: {str(error)}",
|
||||
ephemeral=True
|
||||
)
|
||||
else:
|
||||
await interaction.response.send_message(
|
||||
f"❌ An error occurred: {str(error)}",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
@reset_command.error
|
||||
async def reset_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):
|
||||
"""Handle errors for reset command."""
|
||||
|
||||
411
claude_coordinator/commands.py.backup
Normal file
411
claude_coordinator/commands.py.backup
Normal file
@ -0,0 +1,411 @@
|
||||
"""Slash commands for Discord bot management.
|
||||
|
||||
This module implements application commands (slash commands) for managing
|
||||
Claude sessions across Discord channels. Provides administrative controls
|
||||
for session reset, status monitoring, and model configuration.
|
||||
|
||||
Commands:
|
||||
- /reset: Clear Claude session for a channel (admin only)
|
||||
- /status: Show all active Claude sessions
|
||||
- /model: Switch Claude model for the current channel
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
|
||||
from claude_coordinator.session_manager import SessionManager
|
||||
from claude_coordinator.config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClaudeCommands(commands.Cog):
|
||||
"""Slash commands for Claude Coordinator bot management.
|
||||
|
||||
Provides administrative and monitoring commands for Claude sessions:
|
||||
- Session management (reset)
|
||||
- Status monitoring (all active sessions)
|
||||
- Model configuration (switch between Claude models)
|
||||
"""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
"""Initialize commands cog.
|
||||
|
||||
Args:
|
||||
bot: The Discord bot instance.
|
||||
"""
|
||||
self.bot = bot
|
||||
self.session_manager: SessionManager = bot.session_manager
|
||||
self.config: Config = bot.config
|
||||
logger.info("ClaudeCommands cog initialized")
|
||||
|
||||
@app_commands.command(
|
||||
name="reset",
|
||||
description="Clear Claude session for this channel (admin only)"
|
||||
)
|
||||
@app_commands.describe(
|
||||
channel="Optional: Channel to reset (defaults to current channel)"
|
||||
)
|
||||
@app_commands.checks.has_permissions(manage_messages=True)
|
||||
async def reset_command(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
channel: Optional[discord.TextChannel] = None
|
||||
):
|
||||
"""Reset Claude session for a channel.
|
||||
|
||||
Clears the session history, causing the next message to start
|
||||
a fresh conversation. Requires manage_messages permission.
|
||||
|
||||
Args:
|
||||
interaction: Discord interaction object.
|
||||
channel: Optional channel to reset (defaults to current channel).
|
||||
"""
|
||||
# Determine target channel
|
||||
target_channel = channel or interaction.channel
|
||||
channel_id = str(target_channel.id)
|
||||
|
||||
try:
|
||||
# Check if channel is configured
|
||||
project = self.config.get_project_by_channel(channel_id)
|
||||
if not project:
|
||||
await interaction.response.send_message(
|
||||
f"❌ Channel {target_channel.mention} is not configured for Claude Coordinator.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Check if session exists
|
||||
session = await self.session_manager.get_session(channel_id)
|
||||
|
||||
if not session:
|
||||
await interaction.response.send_message(
|
||||
f"ℹ️ No active session found for {target_channel.mention}.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Create confirmation view
|
||||
view = ResetConfirmView(
|
||||
self.session_manager,
|
||||
channel_id,
|
||||
target_channel,
|
||||
session
|
||||
)
|
||||
|
||||
message_count = session.get('message_count', 0)
|
||||
await interaction.response.send_message(
|
||||
f"⚠️ **Reset Session Confirmation**\n\n"
|
||||
f"Channel: {target_channel.mention}\n"
|
||||
f"Project: **{session.get('project_name', 'Unknown')}**\n"
|
||||
f"Messages: **{message_count}**\n\n"
|
||||
f"Are you sure you want to clear this session? This will delete all conversation history.",
|
||||
view=view,
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Reset confirmation requested for channel {channel_id} "
|
||||
f"by user {interaction.user.id}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error in reset command: {e}")
|
||||
await interaction.response.send_message(
|
||||
f"❌ **Error:** {str(e)}",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
@app_commands.command(
|
||||
name="status",
|
||||
description="Show all active Claude sessions"
|
||||
)
|
||||
async def status_command(self, interaction: discord.Interaction):
|
||||
"""Display status of all active Claude sessions.
|
||||
|
||||
Shows channel name, project, message count, and last activity
|
||||
time for each active session across all configured channels.
|
||||
|
||||
Args:
|
||||
interaction: Discord interaction object.
|
||||
"""
|
||||
try:
|
||||
# Defer response since we might take a moment
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
# Get all sessions
|
||||
sessions = await self.session_manager.list_sessions()
|
||||
stats = await self.session_manager.get_stats()
|
||||
|
||||
if not sessions:
|
||||
embed = discord.Embed(
|
||||
title="📊 Claude Coordinator Status",
|
||||
description="No active sessions.",
|
||||
color=discord.Color.blue()
|
||||
)
|
||||
embed.set_footer(text=f"Database: {self.session_manager.db_path}")
|
||||
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
return
|
||||
|
||||
# Build embed with session information
|
||||
embed = discord.Embed(
|
||||
title="📊 Claude Coordinator Status",
|
||||
description=f"**{stats['total_sessions']}** active session(s)",
|
||||
color=discord.Color.green()
|
||||
)
|
||||
|
||||
# Add session details
|
||||
for session in sessions:
|
||||
channel_id = session['channel_id']
|
||||
|
||||
# Try to get channel name
|
||||
try:
|
||||
channel = self.bot.get_channel(int(channel_id))
|
||||
channel_name = channel.mention if channel else f"Unknown ({channel_id})"
|
||||
except (ValueError, AttributeError):
|
||||
channel_name = f"Unknown ({channel_id})"
|
||||
|
||||
project_name = session.get('project_name') or 'Unknown'
|
||||
message_count = session.get('message_count', 0)
|
||||
last_active = session.get('last_active', '')
|
||||
|
||||
# Calculate time since last activity
|
||||
try:
|
||||
last_active_dt = datetime.fromisoformat(last_active)
|
||||
now = datetime.now()
|
||||
delta = now - last_active_dt
|
||||
|
||||
if delta.days > 0:
|
||||
time_ago = f"{delta.days}d ago"
|
||||
elif delta.seconds >= 3600:
|
||||
hours = delta.seconds // 3600
|
||||
time_ago = f"{hours}h ago"
|
||||
elif delta.seconds >= 60:
|
||||
minutes = delta.seconds // 60
|
||||
time_ago = f"{minutes}m ago"
|
||||
else:
|
||||
time_ago = "just now"
|
||||
except (ValueError, TypeError):
|
||||
time_ago = "unknown"
|
||||
|
||||
embed.add_field(
|
||||
name=f"{channel_name}",
|
||||
value=(
|
||||
f"**Project:** {project_name}\n"
|
||||
f"**Messages:** {message_count}\n"
|
||||
f"**Last Active:** {time_ago}"
|
||||
),
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Add summary footer
|
||||
total_messages = stats.get('total_messages', 0)
|
||||
embed.set_footer(
|
||||
text=f"Total messages: {total_messages} | Database: {self.session_manager.db_path}"
|
||||
)
|
||||
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
logger.info(f"Status command executed by user {interaction.user.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error in status command: {e}")
|
||||
await interaction.followup.send(
|
||||
f"❌ **Error:** {str(e)}",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
@app_commands.command(
|
||||
name="model",
|
||||
description="Switch Claude model for this channel"
|
||||
)
|
||||
@app_commands.describe(
|
||||
model_name="Claude model to use (sonnet, opus, haiku)"
|
||||
)
|
||||
@app_commands.choices(model_name=[
|
||||
app_commands.Choice(name="Claude Sonnet (default)", value="sonnet"),
|
||||
app_commands.Choice(name="Claude Opus (most capable)", value="opus"),
|
||||
app_commands.Choice(name="Claude Haiku (fastest)", value="haiku"),
|
||||
])
|
||||
async def model_command(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
model_name: str
|
||||
):
|
||||
"""Switch Claude model for the current channel.
|
||||
|
||||
Changes the model used for future Claude CLI invocations in this channel.
|
||||
Does not affect existing session history.
|
||||
|
||||
Args:
|
||||
interaction: Discord interaction object.
|
||||
model_name: Model identifier (sonnet, opus, haiku).
|
||||
"""
|
||||
channel_id = str(interaction.channel.id)
|
||||
|
||||
try:
|
||||
# Check if channel is configured
|
||||
project = self.config.get_project_by_channel(channel_id)
|
||||
if not project:
|
||||
await interaction.response.send_message(
|
||||
"❌ This channel is not configured for Claude Coordinator.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Map model names to Claude CLI model identifiers
|
||||
model_mapping = {
|
||||
"sonnet": "claude-sonnet-4-5",
|
||||
"opus": "claude-opus-4-6",
|
||||
"haiku": "claude-3-5-haiku"
|
||||
}
|
||||
|
||||
if model_name not in model_mapping:
|
||||
await interaction.response.send_message(
|
||||
f"❌ Invalid model: {model_name}. Use: sonnet, opus, or haiku",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Update project config with new model
|
||||
old_model = project.model
|
||||
project.model = model_mapping[model_name]
|
||||
|
||||
# Save configuration
|
||||
self.config.save()
|
||||
|
||||
await interaction.response.send_message(
|
||||
f"✅ **Model Updated**\n\n"
|
||||
f"Channel: {interaction.channel.mention}\n"
|
||||
f"Previous: `{old_model or 'default'}`\n"
|
||||
f"New: `{project.model}`\n\n"
|
||||
f"The new model will be used for the next Claude request.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Model switched to {model_name} for channel {channel_id} "
|
||||
f"by user {interaction.user.id}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error in model command: {e}")
|
||||
await interaction.response.send_message(
|
||||
f"❌ **Error:** {str(e)}",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
@reset_command.error
|
||||
async def reset_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):
|
||||
"""Handle errors for reset command."""
|
||||
if isinstance(error, app_commands.MissingPermissions):
|
||||
await interaction.response.send_message(
|
||||
"❌ You need **Manage Messages** permission to use this command.",
|
||||
ephemeral=True
|
||||
)
|
||||
else:
|
||||
logger.exception(f"Unhandled error in reset command: {error}")
|
||||
await interaction.response.send_message(
|
||||
f"❌ An error occurred: {str(error)}",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
class ResetConfirmView(discord.ui.View):
|
||||
"""Confirmation view for /reset command.
|
||||
|
||||
Provides Yes/No buttons for session reset confirmation.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_manager: SessionManager,
|
||||
channel_id: str,
|
||||
channel: discord.TextChannel,
|
||||
session: dict
|
||||
):
|
||||
"""Initialize confirmation view.
|
||||
|
||||
Args:
|
||||
session_manager: SessionManager instance.
|
||||
channel_id: ID of channel to reset.
|
||||
channel: Discord channel object.
|
||||
session: Session data dictionary.
|
||||
"""
|
||||
super().__init__(timeout=60.0) # 60 second timeout
|
||||
self.session_manager = session_manager
|
||||
self.channel_id = channel_id
|
||||
self.channel = channel
|
||||
self.session = session
|
||||
|
||||
@discord.ui.button(label="Yes, Reset", style=discord.ButtonStyle.danger)
|
||||
async def confirm_button(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
button: discord.ui.Button
|
||||
):
|
||||
"""Handle confirmation button click."""
|
||||
try:
|
||||
# Perform reset
|
||||
deleted = await self.session_manager.reset_session(self.channel_id)
|
||||
|
||||
if deleted:
|
||||
await interaction.response.edit_message(
|
||||
content=(
|
||||
f"✅ **Session Reset**\n\n"
|
||||
f"Channel: {self.channel.mention}\n"
|
||||
f"Project: **{self.session.get('project_name', 'Unknown')}**\n\n"
|
||||
f"Session history cleared. The next message will start a fresh conversation."
|
||||
),
|
||||
view=None # Remove buttons
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Session reset for channel {self.channel_id} "
|
||||
f"by user {interaction.user.id}"
|
||||
)
|
||||
else:
|
||||
await interaction.response.edit_message(
|
||||
content="❌ Session not found or already deleted.",
|
||||
view=None
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error during session reset: {e}")
|
||||
await interaction.response.edit_message(
|
||||
content=f"❌ Error resetting session: {str(e)}",
|
||||
view=None
|
||||
)
|
||||
|
||||
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary)
|
||||
async def cancel_button(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
button: discord.ui.Button
|
||||
):
|
||||
"""Handle cancel button click."""
|
||||
await interaction.response.edit_message(
|
||||
content="❌ Reset cancelled. Session remains active.",
|
||||
view=None # Remove buttons
|
||||
)
|
||||
|
||||
async def on_timeout(self):
|
||||
"""Handle view timeout (60 seconds)."""
|
||||
# Disable all buttons on timeout
|
||||
for item in self.children:
|
||||
item.disabled = True
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
"""Setup function to add cog to bot.
|
||||
|
||||
Args:
|
||||
bot: The Discord bot instance.
|
||||
"""
|
||||
await bot.add_cog(ClaudeCommands(bot))
|
||||
logger.info("ClaudeCommands cog loaded")
|
||||
@ -284,6 +284,96 @@ class Config:
|
||||
|
||||
return errors
|
||||
|
||||
def add_project(self, project_config: dict) -> None:
|
||||
"""Add a new project to configuration.
|
||||
|
||||
Args:
|
||||
project_config: Dictionary with project configuration fields:
|
||||
- name: Project name
|
||||
- channel_id: Discord channel ID
|
||||
- project_dir: Absolute path to project directory
|
||||
- allowed_tools: List of allowed tool names
|
||||
- model: Claude model to use
|
||||
- system_prompt: Optional inline system prompt
|
||||
- system_prompt_file: Optional path to system prompt file
|
||||
|
||||
Raises:
|
||||
ValueError: If required fields are missing or channel_id already configured.
|
||||
"""
|
||||
# Validate project_config has required fields
|
||||
required = ['name', 'channel_id', 'project_dir', 'allowed_tools', 'model']
|
||||
for field in required:
|
||||
if field not in project_config:
|
||||
raise ValueError(f"Missing required field: {field}")
|
||||
|
||||
# Check for duplicate channel_id
|
||||
if any(p.channel_id == project_config['channel_id'] for p in self.projects.values()):
|
||||
raise ValueError(f"Channel {project_config['channel_id']} already configured")
|
||||
|
||||
# Create ProjectConfig instance
|
||||
project = ProjectConfig(
|
||||
name=project_config['name'],
|
||||
channel_id=project_config['channel_id'],
|
||||
project_dir=project_config['project_dir'],
|
||||
allowed_tools=project_config['allowed_tools'],
|
||||
system_prompt=project_config.get('system_prompt'),
|
||||
system_prompt_file=Path(project_config['system_prompt_file']) if project_config.get('system_prompt_file') else None,
|
||||
model=project_config['model']
|
||||
)
|
||||
|
||||
# Add to projects dict and channel map
|
||||
self.projects[project_config['name']] = project
|
||||
self._channel_map[project_config['channel_id']] = project_config['name']
|
||||
|
||||
logger.info(f"Added project '{project_config['name']}' for channel {project_config['channel_id']}")
|
||||
|
||||
def save(self) -> None:
|
||||
"""Save configuration back to YAML file.
|
||||
|
||||
Writes the current configuration to disk atomically (write to temp file,
|
||||
then rename) to prevent corruption if interrupted.
|
||||
|
||||
Raises:
|
||||
IOError: If unable to write configuration file.
|
||||
"""
|
||||
config_data = {
|
||||
'projects': {}
|
||||
}
|
||||
|
||||
# Build projects section
|
||||
for name, project in self.projects.items():
|
||||
config_data['projects'][name] = {
|
||||
'channel_id': project.channel_id,
|
||||
'project_dir': project.project_dir,
|
||||
'allowed_tools': project.allowed_tools,
|
||||
'model': project.model,
|
||||
}
|
||||
|
||||
# Add optional fields if present
|
||||
if project.system_prompt:
|
||||
config_data['projects'][name]['system_prompt'] = project.system_prompt
|
||||
|
||||
if project.system_prompt_file:
|
||||
config_data['projects'][name]['system_prompt_file'] = str(project.system_prompt_file)
|
||||
|
||||
# Write atomically (write to temp file, then rename)
|
||||
temp_path = Path(str(self.config_path) + '.tmp')
|
||||
|
||||
try:
|
||||
with open(temp_path, 'w') as f:
|
||||
yaml.dump(config_data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Atomic rename
|
||||
os.replace(temp_path, self.config_path)
|
||||
logger.info(f"Saved configuration to {self.config_path}")
|
||||
|
||||
except Exception as e:
|
||||
# Clean up temp file on error
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
raise IOError(f"Failed to save configuration: {e}")
|
||||
|
||||
|
||||
# Legacy method for backward compatibility
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get configuration value by key (legacy method).
|
||||
|
||||
300
claude_coordinator/config.py.backup
Normal file
300
claude_coordinator/config.py.backup
Normal file
@ -0,0 +1,300 @@
|
||||
"""Configuration management for Claude Discord Coordinator.
|
||||
|
||||
Loads and validates YAML configuration files mapping Discord channels to
|
||||
project configurations with tool restrictions and custom prompts.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
import os
|
||||
import yaml
|
||||
|
||||
|
||||
# Known valid tool names from Claude API
|
||||
VALID_TOOLS = {
|
||||
"Bash",
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectConfig:
|
||||
"""Configuration for a single project mapped to a Discord channel.
|
||||
|
||||
Attributes:
|
||||
name: Project identifier/name.
|
||||
channel_id: Discord channel ID (as string).
|
||||
project_dir: Absolute path to project directory.
|
||||
allowed_tools: List of tool names allowed for this project.
|
||||
system_prompt: Optional inline system prompt.
|
||||
system_prompt_file: Optional path to system prompt file.
|
||||
model: Model to use (sonnet, opus, haiku).
|
||||
"""
|
||||
|
||||
name: str
|
||||
channel_id: str
|
||||
project_dir: str
|
||||
allowed_tools: List[str] = field(default_factory=lambda: list(VALID_TOOLS))
|
||||
system_prompt: Optional[str] = None
|
||||
system_prompt_file: Optional[Path] = None
|
||||
model: str = "sonnet"
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate and normalize configuration after initialization."""
|
||||
# Expand environment variables in paths
|
||||
self.project_dir = os.path.expandvars(self.project_dir)
|
||||
if self.system_prompt_file:
|
||||
self.system_prompt_file = Path(os.path.expandvars(str(self.system_prompt_file)))
|
||||
|
||||
def get_system_prompt(self) -> Optional[str]:
|
||||
"""Get the system prompt, loading from file if necessary.
|
||||
|
||||
Returns:
|
||||
System prompt text or None if not configured.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If system_prompt_file is set but doesn't exist.
|
||||
"""
|
||||
if self.system_prompt:
|
||||
return self.system_prompt
|
||||
|
||||
if self.system_prompt_file:
|
||||
if not self.system_prompt_file.exists():
|
||||
raise FileNotFoundError(
|
||||
f"System prompt file not found: {self.system_prompt_file}"
|
||||
)
|
||||
return self.system_prompt_file.read_text()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class Config:
|
||||
"""Configuration manager for bot settings.
|
||||
|
||||
Loads YAML configuration mapping Discord channels to project settings,
|
||||
validates configuration structure, and provides lookup methods.
|
||||
|
||||
Attributes:
|
||||
config_path: Path to the YAML configuration file.
|
||||
projects: Dictionary mapping project names to ProjectConfig objects.
|
||||
_channel_map: Internal mapping of channel IDs to project names.
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: Optional[str] = None):
|
||||
"""Initialize configuration manager.
|
||||
|
||||
Args:
|
||||
config_path: Path to config file. If None, uses default location
|
||||
~/.claude-coordinator/config.yaml
|
||||
"""
|
||||
if config_path is None:
|
||||
config_path = os.path.expanduser("~/.claude-coordinator/config.yaml")
|
||||
|
||||
self.config_path = Path(config_path)
|
||||
self.projects: Dict[str, ProjectConfig] = {}
|
||||
self._channel_map: Dict[str, str] = {}
|
||||
|
||||
def load(self) -> None:
|
||||
"""Load configuration from YAML file.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config file does not exist.
|
||||
yaml.YAMLError: If config file is invalid YAML.
|
||||
ValueError: If configuration validation fails.
|
||||
"""
|
||||
if not self.config_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Configuration file not found: {self.config_path}\n"
|
||||
f"Create a config file at this location or specify a different path."
|
||||
)
|
||||
|
||||
try:
|
||||
with open(self.config_path, "r") as f:
|
||||
data = yaml.safe_load(f)
|
||||
except yaml.YAMLError as e:
|
||||
raise yaml.YAMLError(
|
||||
f"Invalid YAML in configuration file {self.config_path}:\n{e}"
|
||||
)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Configuration file must contain a YAML dictionary")
|
||||
|
||||
if "projects" not in data:
|
||||
raise ValueError("Configuration must contain a 'projects' section")
|
||||
|
||||
projects_data = data["projects"]
|
||||
if not isinstance(projects_data, dict):
|
||||
raise ValueError("'projects' section must be a dictionary")
|
||||
|
||||
# Parse each project configuration
|
||||
self.projects = {}
|
||||
self._channel_map = {}
|
||||
|
||||
for project_name, project_data in projects_data.items():
|
||||
if not isinstance(project_data, dict):
|
||||
raise ValueError(
|
||||
f"Project '{project_name}' configuration must be a dictionary"
|
||||
)
|
||||
|
||||
# Create ProjectConfig
|
||||
try:
|
||||
config = self._parse_project_config(project_name, project_data)
|
||||
self.projects[project_name] = config
|
||||
self._channel_map[config.channel_id] = project_name
|
||||
except (ValueError, KeyError) as e:
|
||||
raise ValueError(f"Error in project '{project_name}': {e}")
|
||||
|
||||
# Validate configuration
|
||||
errors = self.validate()
|
||||
if errors:
|
||||
raise ValueError(
|
||||
f"Configuration validation failed:\n" + "\n".join(f" - {e}" for e in errors)
|
||||
)
|
||||
|
||||
def _parse_project_config(self, name: str, data: Dict[str, Any]) -> ProjectConfig:
|
||||
"""Parse a single project configuration.
|
||||
|
||||
Args:
|
||||
name: Project name.
|
||||
data: Project configuration dictionary.
|
||||
|
||||
Returns:
|
||||
ProjectConfig object.
|
||||
|
||||
Raises:
|
||||
KeyError: If required fields are missing.
|
||||
ValueError: If field values are invalid.
|
||||
"""
|
||||
# Required fields
|
||||
if "channel_id" not in data:
|
||||
raise KeyError("'channel_id' is required")
|
||||
if "project_dir" not in data:
|
||||
raise KeyError("'project_dir' is required")
|
||||
|
||||
channel_id = str(data["channel_id"]) # Ensure string
|
||||
project_dir = data["project_dir"]
|
||||
|
||||
# Optional fields with defaults
|
||||
allowed_tools = data.get("allowed_tools", list(VALID_TOOLS))
|
||||
system_prompt = data.get("system_prompt")
|
||||
system_prompt_file = data.get("system_prompt_file")
|
||||
model = data.get("model", "sonnet")
|
||||
|
||||
# Convert system_prompt_file to Path if present
|
||||
if system_prompt_file:
|
||||
system_prompt_file = Path(system_prompt_file)
|
||||
|
||||
return ProjectConfig(
|
||||
name=name,
|
||||
channel_id=channel_id,
|
||||
project_dir=project_dir,
|
||||
allowed_tools=allowed_tools,
|
||||
system_prompt=system_prompt,
|
||||
system_prompt_file=system_prompt_file,
|
||||
model=model,
|
||||
)
|
||||
|
||||
def get_project_by_channel(self, channel_id: str) -> Optional[ProjectConfig]:
|
||||
"""Get project configuration by Discord channel ID.
|
||||
|
||||
Args:
|
||||
channel_id: Discord channel ID as string.
|
||||
|
||||
Returns:
|
||||
ProjectConfig if channel is mapped, None otherwise.
|
||||
"""
|
||||
project_name = self._channel_map.get(str(channel_id))
|
||||
if project_name:
|
||||
return self.projects[project_name]
|
||||
return None
|
||||
|
||||
def get_all_projects(self) -> Dict[str, ProjectConfig]:
|
||||
"""Get all configured projects.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping project names to ProjectConfig objects.
|
||||
"""
|
||||
return self.projects.copy()
|
||||
|
||||
def validate(self) -> List[str]:
|
||||
"""Validate configuration structure and values.
|
||||
|
||||
Returns:
|
||||
List of validation error messages (empty if valid).
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Check for duplicate channel IDs
|
||||
channel_ids: Dict[str, List[str]] = {}
|
||||
for project_name, config in self.projects.items():
|
||||
channel_id = config.channel_id
|
||||
if channel_id not in channel_ids:
|
||||
channel_ids[channel_id] = []
|
||||
channel_ids[channel_id].append(project_name)
|
||||
|
||||
for channel_id, projects in channel_ids.items():
|
||||
if len(projects) > 1:
|
||||
errors.append(
|
||||
f"Duplicate channel_id '{channel_id}' in projects: {', '.join(projects)}"
|
||||
)
|
||||
|
||||
# Validate each project
|
||||
for project_name, config in self.projects.items():
|
||||
# Validate project directory exists
|
||||
project_path = Path(config.project_dir)
|
||||
if not project_path.exists():
|
||||
errors.append(
|
||||
f"Project '{project_name}': directory does not exist: {config.project_dir}"
|
||||
)
|
||||
elif not project_path.is_dir():
|
||||
errors.append(
|
||||
f"Project '{project_name}': path is not a directory: {config.project_dir}"
|
||||
)
|
||||
|
||||
# Validate tool names
|
||||
invalid_tools = set(config.allowed_tools) - VALID_TOOLS
|
||||
if invalid_tools:
|
||||
errors.append(
|
||||
f"Project '{project_name}': invalid tool names: {', '.join(sorted(invalid_tools))}\n"
|
||||
f" Valid tools: {', '.join(sorted(VALID_TOOLS))}"
|
||||
)
|
||||
|
||||
# Validate system prompt file exists if specified
|
||||
if config.system_prompt_file:
|
||||
if not config.system_prompt_file.exists():
|
||||
errors.append(
|
||||
f"Project '{project_name}': system_prompt_file does not exist: "
|
||||
f"{config.system_prompt_file}"
|
||||
)
|
||||
|
||||
# Validate model choice
|
||||
valid_models = {"sonnet", "opus", "haiku"}
|
||||
if config.model not in valid_models:
|
||||
errors.append(
|
||||
f"Project '{project_name}': invalid model '{config.model}'\n"
|
||||
f" Valid models: {', '.join(sorted(valid_models))}"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
# Legacy method for backward compatibility
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get configuration value by key (legacy method).
|
||||
|
||||
Args:
|
||||
key: Configuration key (supports dot notation).
|
||||
default: Default value if key not found.
|
||||
|
||||
Returns:
|
||||
Configuration value or default.
|
||||
"""
|
||||
# This method is kept for backward compatibility but not used
|
||||
# in the new channel-to-project mapping system
|
||||
return default
|
||||
@ -123,12 +123,116 @@ New: claude-opus-4-6
|
||||
The new model will be used for the next Claude request.
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
### `/add-project <project_name> <git_url> [channel] [model]`
|
||||
|
||||
Dynamically add a new project configuration without restarting the bot.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
/add-project project_name:my-project git_url:https://git.manticorum.com/cal/my-project.git
|
||||
/add-project project_name:paper-dynasty git_url:https://git.example.com/pd.git channel:#dev model:opus
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `project_name` (required) - Short name for the project (lowercase, alphanumeric, hyphens/underscores only)
|
||||
- `git_url` (required) - Git repository URL to clone
|
||||
- `channel` (optional) - Channel to map (defaults to current channel)
|
||||
- `model` (optional) - Claude model to use: sonnet (default), opus, or haiku
|
||||
|
||||
**Features:**
|
||||
- Requires **Administrator** permission
|
||||
- Clones repository to `/opt/projects/{project_name}`
|
||||
- Uses shallow clone (`--depth 1`) for faster cloning
|
||||
- Validates project name format
|
||||
- Checks for duplicate channel configuration
|
||||
- Validates cloned repository structure
|
||||
- Updates configuration file automatically
|
||||
- No bot restart required - changes take effect immediately
|
||||
- Rolls back changes on failure
|
||||
|
||||
**Workflow:**
|
||||
1. Validates inputs (project name format, channel not already configured)
|
||||
2. Clones git repository with 2-minute timeout
|
||||
3. Validates cloned directory is a valid git repository
|
||||
4. Adds project to configuration
|
||||
5. Saves configuration atomically to disk
|
||||
6. Reloads configuration (live update!)
|
||||
|
||||
**Example Output:**
|
||||
```
|
||||
✅ Project Added Successfully
|
||||
|
||||
Project: paper-dynasty
|
||||
Channel: #development
|
||||
Model: opus
|
||||
Directory: /opt/projects/paper-dynasty
|
||||
Repository: https://git.manticorum.com/cal/paper-dynasty.git
|
||||
|
||||
You can now @mention the bot in this channel!
|
||||
```
|
||||
|
||||
**Error Handling:**
|
||||
|
||||
**Invalid Project Name:**
|
||||
```
|
||||
❌ Project name must be lowercase alphanumeric with hyphens/underscores only
|
||||
```
|
||||
|
||||
**Channel Already Configured:**
|
||||
```
|
||||
❌ Channel #development is already configured!
|
||||
```
|
||||
|
||||
**Directory Already Exists:**
|
||||
```
|
||||
❌ Directory already exists: /opt/projects/paper-dynasty
|
||||
```
|
||||
|
||||
**Git Clone Failure:**
|
||||
```
|
||||
❌ Git clone failed:
|
||||
fatal: repository 'https://git.example.com/invalid.git' not found
|
||||
```
|
||||
|
||||
**Git Clone Timeout:**
|
||||
```
|
||||
❌ Git clone timed out (>2 minutes)
|
||||
```
|
||||
|
||||
**Not a Git Repository:**
|
||||
```
|
||||
❌ Cloned directory doesn't appear to be a git repository
|
||||
```
|
||||
|
||||
**Configuration Error (with Rollback):**
|
||||
```
|
||||
❌ Failed to update configuration: Disk full
|
||||
|
||||
Rolled back changes.
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- Repository must be publicly accessible or SSH keys must be configured on the bot server
|
||||
- Project name becomes the directory name in `/opt/projects/`
|
||||
- Default allowed tools: Bash, Read, Write, Edit, Glob, Grep
|
||||
- Configuration is saved to `~/.claude-coordinator/config.yaml`
|
||||
- Use `--depth 1` shallow clone to save disk space and time
|
||||
- Rollback automatically removes cloned directory if configuration fails
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Permission Requirements
|
||||
|
||||
### `/reset`
|
||||
- **Required:** Manage Messages permission
|
||||
|
||||
### `/add-project`
|
||||
- **Required:** Administrator permission
|
||||
- **Scope:** Server-wide administration
|
||||
- **Scope:** Per-channel or server-wide
|
||||
|
||||
### `/status`
|
||||
@ -210,4 +314,4 @@ Test coverage:
|
||||
- ✅ Permission checks and error handlers
|
||||
- ✅ Cog initialization and setup
|
||||
|
||||
**Total:** 18 test cases, all passing
|
||||
**Total:** 30 test cases, all passing (18 original + 12 for /add-project)
|
||||
|
||||
@ -382,3 +382,446 @@ class TestCogSetup:
|
||||
mock_bot.add_cog.assert_called_once()
|
||||
call_args = mock_bot.add_cog.call_args
|
||||
assert isinstance(call_args[0][0], ClaudeCommands)
|
||||
"""
|
||||
Tests for /add-project command.
|
||||
|
||||
Tests cover project addition workflow with:
|
||||
- Success scenarios
|
||||
- Validation (project name, duplicate channel)
|
||||
- Git clone handling (success, failure, timeout)
|
||||
- Configuration management
|
||||
- Rollback on errors
|
||||
- Permission checks
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch, call
|
||||
|
||||
import discord
|
||||
import pytest
|
||||
from discord import app_commands
|
||||
|
||||
from claude_coordinator.commands import ClaudeCommands
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bot():
|
||||
"""Create a mock Discord bot."""
|
||||
bot = MagicMock()
|
||||
bot.session_manager = AsyncMock()
|
||||
bot.config = MagicMock()
|
||||
return bot
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_interaction():
|
||||
"""Create a mock Discord interaction."""
|
||||
interaction = MagicMock(spec=discord.Interaction)
|
||||
interaction.response = AsyncMock()
|
||||
interaction.followup = AsyncMock()
|
||||
interaction.channel = MagicMock()
|
||||
interaction.channel.id = 123456789
|
||||
interaction.channel.mention = "#test-channel"
|
||||
interaction.user = MagicMock()
|
||||
interaction.user.id = 987654321
|
||||
return interaction
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def commands_cog(mock_bot):
|
||||
"""Create ClaudeCommands cog instance."""
|
||||
return ClaudeCommands(mock_bot)
|
||||
|
||||
|
||||
class TestAddProjectCommand:
|
||||
"""Tests for /add-project command."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_project_success(self, commands_cog, mock_interaction):
|
||||
"""Test successful project addition with all steps completing."""
|
||||
# Setup mocks
|
||||
commands_cog.config.get_project_by_channel.return_value = None
|
||||
|
||||
# Mock git clone subprocess
|
||||
mock_process = AsyncMock()
|
||||
mock_process.returncode = 0
|
||||
mock_process.communicate.return_value = (b'', b'')
|
||||
|
||||
# Mock file system operations
|
||||
with patch('asyncio.create_subprocess_exec', return_value=mock_process), \
|
||||
patch('asyncio.wait_for', return_value=(b'', b'')), \
|
||||
patch('os.path.exists') as mock_exists, \
|
||||
patch('os.path.join', return_value='/opt/projects/test-project/.git'):
|
||||
|
||||
# First exists() check: directory doesn't exist (False)
|
||||
# Second exists() check: .git directory exists (True)
|
||||
mock_exists.side_effect = [False, True]
|
||||
|
||||
# Mock config operations
|
||||
commands_cog.config.add_project = MagicMock()
|
||||
commands_cog.config.save = MagicMock()
|
||||
commands_cog.config.load = MagicMock()
|
||||
|
||||
# Execute command
|
||||
await commands_cog.add_project_command.callback(
|
||||
commands_cog,
|
||||
mock_interaction,
|
||||
"test-project",
|
||||
"https://git.example.com/test.git",
|
||||
None,
|
||||
"sonnet"
|
||||
)
|
||||
|
||||
# Verify defer was called
|
||||
mock_interaction.response.defer.assert_called_once_with(ephemeral=True)
|
||||
|
||||
# Verify config was updated
|
||||
commands_cog.config.add_project.assert_called_once()
|
||||
commands_cog.config.save.assert_called_once()
|
||||
commands_cog.config.load.assert_called_once()
|
||||
|
||||
# Verify success message
|
||||
mock_interaction.followup.send.assert_called_once()
|
||||
call_args = mock_interaction.followup.send.call_args
|
||||
assert 'embed' in call_args[1]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_project_duplicate_channel(self, commands_cog, mock_interaction):
|
||||
"""Test add-project rejects if channel already configured."""
|
||||
# Setup: channel already has a project
|
||||
mock_project = MagicMock()
|
||||
commands_cog.config.get_project_by_channel.return_value = mock_project
|
||||
|
||||
# Execute command
|
||||
await commands_cog.add_project_command.callback(
|
||||
commands_cog,
|
||||
mock_interaction,
|
||||
"test-project",
|
||||
"https://git.example.com/test.git",
|
||||
None,
|
||||
"sonnet"
|
||||
)
|
||||
|
||||
# Verify error message
|
||||
mock_interaction.response.send_message.assert_called_once()
|
||||
call_args = mock_interaction.response.send_message.call_args
|
||||
assert "already configured" in call_args[0][0]
|
||||
assert call_args[1]['ephemeral'] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_project_invalid_name(self, commands_cog, mock_interaction):
|
||||
"""Test add-project rejects invalid project names."""
|
||||
# Setup
|
||||
commands_cog.config.get_project_by_channel.return_value = None
|
||||
|
||||
invalid_names = [
|
||||
"Test-Project", # Uppercase
|
||||
"test project", # Space
|
||||
"test@project", # Special char
|
||||
"test.project", # Dot
|
||||
]
|
||||
|
||||
for invalid_name in invalid_names:
|
||||
mock_interaction.response.reset_mock()
|
||||
|
||||
# Execute command
|
||||
await commands_cog.add_project_command.callback(
|
||||
commands_cog,
|
||||
mock_interaction,
|
||||
invalid_name,
|
||||
"https://git.example.com/test.git",
|
||||
None,
|
||||
"sonnet"
|
||||
)
|
||||
|
||||
# Verify error message
|
||||
mock_interaction.response.send_message.assert_called_once()
|
||||
call_args = mock_interaction.response.send_message.call_args
|
||||
assert "lowercase alphanumeric" in call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_project_directory_exists(self, commands_cog, mock_interaction):
|
||||
"""Test add-project fails if directory already exists."""
|
||||
# Setup
|
||||
commands_cog.config.get_project_by_channel.return_value = None
|
||||
|
||||
with patch('os.path.exists', return_value=True):
|
||||
# Execute command
|
||||
await commands_cog.add_project_command.callback(
|
||||
commands_cog,
|
||||
mock_interaction,
|
||||
"test-project",
|
||||
"https://git.example.com/test.git",
|
||||
None,
|
||||
"sonnet"
|
||||
)
|
||||
|
||||
# Verify defer was called
|
||||
mock_interaction.response.defer.assert_called_once()
|
||||
|
||||
# Verify error message
|
||||
mock_interaction.followup.send.assert_called_once()
|
||||
call_args = mock_interaction.followup.send.call_args
|
||||
assert "already exists" in call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_project_git_clone_failure(self, commands_cog, mock_interaction):
|
||||
"""Test add-project handles git clone failure gracefully."""
|
||||
# Setup
|
||||
commands_cog.config.get_project_by_channel.return_value = None
|
||||
|
||||
# Mock git clone subprocess with failure
|
||||
mock_process = AsyncMock()
|
||||
mock_process.returncode = 1
|
||||
mock_process.communicate.return_value = (b'', b'fatal: repository not found')
|
||||
|
||||
with patch('asyncio.create_subprocess_exec', return_value=mock_process), \
|
||||
patch('asyncio.wait_for', return_value=(b'', b'fatal: repository not found')), \
|
||||
patch('os.path.exists', return_value=False):
|
||||
|
||||
# Execute command
|
||||
await commands_cog.add_project_command.callback(
|
||||
commands_cog,
|
||||
mock_interaction,
|
||||
"test-project",
|
||||
"https://git.example.com/test.git",
|
||||
None,
|
||||
"sonnet"
|
||||
)
|
||||
|
||||
# Verify error message
|
||||
mock_interaction.followup.send.assert_called_once()
|
||||
call_args = mock_interaction.followup.send.call_args
|
||||
assert "Git clone failed" in call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_project_git_timeout(self, commands_cog, mock_interaction):
|
||||
"""Test add-project handles git clone timeout."""
|
||||
# Setup
|
||||
commands_cog.config.get_project_by_channel.return_value = None
|
||||
|
||||
# Mock git clone subprocess
|
||||
mock_process = AsyncMock()
|
||||
mock_process.kill = MagicMock()
|
||||
mock_process.wait = AsyncMock()
|
||||
|
||||
with patch('asyncio.create_subprocess_exec', return_value=mock_process), \
|
||||
patch('asyncio.wait_for', side_effect=asyncio.TimeoutError), \
|
||||
patch('os.path.exists', return_value=False):
|
||||
|
||||
# Execute command
|
||||
await commands_cog.add_project_command.callback(
|
||||
commands_cog,
|
||||
mock_interaction,
|
||||
"test-project",
|
||||
"https://git.example.com/test.git",
|
||||
None,
|
||||
"sonnet"
|
||||
)
|
||||
|
||||
# Verify process was killed
|
||||
mock_process.kill.assert_called_once()
|
||||
|
||||
# Verify error message
|
||||
mock_interaction.followup.send.assert_called_once()
|
||||
call_args = mock_interaction.followup.send.call_args
|
||||
assert "timed out" in call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_project_not_git_repo(self, commands_cog, mock_interaction):
|
||||
"""Test add-project validates cloned directory is a git repo."""
|
||||
# Setup
|
||||
commands_cog.config.get_project_by_channel.return_value = None
|
||||
|
||||
# Mock git clone subprocess (success)
|
||||
mock_process = AsyncMock()
|
||||
mock_process.returncode = 0
|
||||
mock_process.communicate.return_value = (b'', b'')
|
||||
|
||||
with patch('asyncio.create_subprocess_exec', return_value=mock_process), \
|
||||
patch('asyncio.wait_for', return_value=(b'', b'')), \
|
||||
patch('os.path.exists') as mock_exists, \
|
||||
patch('os.path.join', return_value='/opt/projects/test-project/.git'), \
|
||||
patch('shutil.rmtree') as mock_rmtree:
|
||||
|
||||
# First exists(): directory doesn't exist (False)
|
||||
# Second exists(): .git directory doesn't exist (False)
|
||||
mock_exists.side_effect = [False, False]
|
||||
|
||||
# Execute command
|
||||
await commands_cog.add_project_command.callback(
|
||||
commands_cog,
|
||||
mock_interaction,
|
||||
"test-project",
|
||||
"https://git.example.com/test.git",
|
||||
None,
|
||||
"sonnet"
|
||||
)
|
||||
|
||||
# Verify cleanup was attempted
|
||||
mock_rmtree.assert_called_once()
|
||||
|
||||
# Verify error message
|
||||
mock_interaction.followup.send.assert_called_once()
|
||||
call_args = mock_interaction.followup.send.call_args
|
||||
assert "doesn't appear to be a git repository" in call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_project_config_rollback(self, commands_cog, mock_interaction):
|
||||
"""Test add-project rolls back on config save failure."""
|
||||
# Setup
|
||||
commands_cog.config.get_project_by_channel.return_value = None
|
||||
|
||||
# Mock git clone subprocess (success)
|
||||
mock_process = AsyncMock()
|
||||
mock_process.returncode = 0
|
||||
mock_process.communicate.return_value = (b'', b'')
|
||||
|
||||
with patch('asyncio.create_subprocess_exec', return_value=mock_process), \
|
||||
patch('asyncio.wait_for', return_value=(b'', b'')), \
|
||||
patch('os.path.exists') as mock_exists, \
|
||||
patch('os.path.join', return_value='/opt/projects/test-project/.git'), \
|
||||
patch('shutil.rmtree') as mock_rmtree:
|
||||
|
||||
# exists() checks: directory doesn't exist, .git exists
|
||||
mock_exists.side_effect = [False, True]
|
||||
|
||||
# Mock config operations with save() failing
|
||||
commands_cog.config.add_project = MagicMock()
|
||||
commands_cog.config.save = MagicMock(side_effect=IOError("Disk full"))
|
||||
|
||||
# Execute command
|
||||
await commands_cog.add_project_command.callback(
|
||||
commands_cog,
|
||||
mock_interaction,
|
||||
"test-project",
|
||||
"https://git.example.com/test.git",
|
||||
None,
|
||||
"sonnet"
|
||||
)
|
||||
|
||||
# Verify rollback was performed
|
||||
mock_rmtree.assert_called_once_with('/opt/projects/test-project')
|
||||
|
||||
# Verify error message mentions rollback
|
||||
mock_interaction.followup.send.assert_called_once()
|
||||
call_args = mock_interaction.followup.send.call_args
|
||||
assert "Rolled back" in call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_project_permission_check(self, commands_cog, mock_interaction):
|
||||
"""Test add-project command has administrator permission requirement."""
|
||||
# Verify the command has the permission decorator
|
||||
assert hasattr(commands_cog.add_project_command, 'checks')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_project_error_handler(self, commands_cog, mock_interaction):
|
||||
"""Test add-project permission error handler."""
|
||||
# Create permission error
|
||||
error = app_commands.MissingPermissions(['administrator'])
|
||||
|
||||
# Call error handler
|
||||
await commands_cog.add_project_error(mock_interaction, error)
|
||||
|
||||
# Verify error message was sent
|
||||
mock_interaction.response.send_message.assert_called_once()
|
||||
call_args = mock_interaction.response.send_message.call_args
|
||||
assert "Administrator" in call_args[0][0]
|
||||
assert call_args[1]['ephemeral'] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_project_with_target_channel(self, commands_cog, mock_interaction):
|
||||
"""Test add-project with explicit target channel parameter."""
|
||||
# Setup target channel
|
||||
target_channel = MagicMock()
|
||||
target_channel.id = 999888777
|
||||
target_channel.mention = "#target-channel"
|
||||
|
||||
# Setup mocks
|
||||
commands_cog.config.get_project_by_channel.return_value = None
|
||||
|
||||
# Mock git clone subprocess
|
||||
mock_process = AsyncMock()
|
||||
mock_process.returncode = 0
|
||||
mock_process.communicate.return_value = (b'', b'')
|
||||
|
||||
with patch('asyncio.create_subprocess_exec', return_value=mock_process), \
|
||||
patch('asyncio.wait_for', return_value=(b'', b'')), \
|
||||
patch('os.path.exists') as mock_exists, \
|
||||
patch('os.path.join', return_value='/opt/projects/test-project/.git'):
|
||||
|
||||
mock_exists.side_effect = [False, True]
|
||||
|
||||
# Mock config operations
|
||||
commands_cog.config.add_project = MagicMock()
|
||||
commands_cog.config.save = MagicMock()
|
||||
commands_cog.config.load = MagicMock()
|
||||
|
||||
# Execute command with target channel
|
||||
await commands_cog.add_project_command.callback(
|
||||
commands_cog,
|
||||
mock_interaction,
|
||||
"test-project",
|
||||
"https://git.example.com/test.git",
|
||||
target_channel, # Explicit channel
|
||||
"opus"
|
||||
)
|
||||
|
||||
# Verify target channel was used
|
||||
add_project_call = commands_cog.config.add_project.call_args
|
||||
assert add_project_call[0][0]['channel_id'] == '999888777'
|
||||
assert add_project_call[0][0]['model'] == 'opus'
|
||||
|
||||
|
||||
class TestAddProjectIntegration:
|
||||
"""Integration tests for add-project configuration."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_structure(self, commands_cog, mock_interaction):
|
||||
"""Test add-project creates correct config structure."""
|
||||
# Setup
|
||||
commands_cog.config.get_project_by_channel.return_value = None
|
||||
|
||||
# Capture the add_project call
|
||||
captured_config = None
|
||||
def capture_config(config):
|
||||
nonlocal captured_config
|
||||
captured_config = config
|
||||
|
||||
commands_cog.config.add_project = MagicMock(side_effect=capture_config)
|
||||
commands_cog.config.save = MagicMock()
|
||||
commands_cog.config.load = MagicMock()
|
||||
|
||||
# Mock git operations
|
||||
mock_process = AsyncMock()
|
||||
mock_process.returncode = 0
|
||||
mock_process.communicate.return_value = (b'', b'')
|
||||
|
||||
with patch('asyncio.create_subprocess_exec', return_value=mock_process), \
|
||||
patch('asyncio.wait_for', return_value=(b'', b'')), \
|
||||
patch('os.path.exists') as mock_exists, \
|
||||
patch('os.path.join', return_value='/opt/projects/my-project/.git'):
|
||||
|
||||
mock_exists.side_effect = [False, True]
|
||||
|
||||
# Execute
|
||||
await commands_cog.add_project_command.callback(
|
||||
commands_cog,
|
||||
mock_interaction,
|
||||
"my-project",
|
||||
"https://git.example.com/my-project.git",
|
||||
None,
|
||||
"haiku"
|
||||
)
|
||||
|
||||
# Verify config structure
|
||||
assert captured_config is not None
|
||||
assert captured_config['name'] == 'my-project'
|
||||
assert captured_config['channel_id'] == '123456789'
|
||||
assert captured_config['project_dir'] == '/opt/projects/my-project'
|
||||
assert captured_config['model'] == 'haiku'
|
||||
assert 'Bash' in captured_config['allowed_tools']
|
||||
assert 'Read' in captured_config['allowed_tools']
|
||||
|
||||
Loading…
Reference in New Issue
Block a user