asd
This commit is contained in:
parent
e47c919576
commit
80083bd14a
@ -193,10 +193,20 @@ async def send_discord_message_via_api(channel_id: int, content: str, timeout: f
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# Message data
|
||||
data = {
|
||||
"content": content
|
||||
}
|
||||
# Message data - allow for complex payloads (like embeds)
|
||||
data: Dict[str, Any]
|
||||
if isinstance(content, str):
|
||||
data = {"content": content}
|
||||
elif isinstance(content, dict): # Assuming dict means it's a full payload like {"embeds": [...]}
|
||||
data = content
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Invalid content type for sending message. Must be string or dict.",
|
||||
"error": "invalid_content_type"
|
||||
}
|
||||
|
||||
log.debug(f"Sending message to channel {channel_id} with data: {data}")
|
||||
|
||||
try:
|
||||
# Use global http_session if available, otherwise create a new one
|
||||
@ -508,6 +518,32 @@ app.mount("/api", api_app)
|
||||
app.mount("/discordapi", discordapi_app)
|
||||
app.mount("/dashboard/api", dashboard_api_app) # Mount the new dashboard API
|
||||
|
||||
# Import and mount webhook endpoints
|
||||
try:
|
||||
from .webhook_endpoints import router as webhook_router # Relative import
|
||||
app.mount("/webhook", webhook_router) # Mount directly on the main app for simplicity
|
||||
# Or, if you prefer to nest it under /api:
|
||||
# api_app.include_router(webhook_router, prefix="/webhooks", tags=["Webhooks"])
|
||||
log.info("Webhook endpoints loaded and mounted successfully at /webhook")
|
||||
except ImportError as e:
|
||||
log.error(f"Could not import or mount webhook endpoints: {e}")
|
||||
# Attempt to find the module for debugging
|
||||
try:
|
||||
import sys
|
||||
log.info(f"Python path: {sys.path}")
|
||||
import os
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
log.info(f"Current directory for webhook_endpoints: {current_dir}")
|
||||
files_in_current_dir = os.listdir(current_dir)
|
||||
log.info(f"Files in {current_dir}: {files_in_current_dir}")
|
||||
if 'webhook_endpoints.py' in files_in_current_dir:
|
||||
log.info("webhook_endpoints.py found in current directory.")
|
||||
else:
|
||||
log.warning("webhook_endpoints.py NOT found in current directory.")
|
||||
except Exception as e_debug:
|
||||
log.error(f"Debug check for webhook_endpoints failed: {e_debug}")
|
||||
|
||||
|
||||
# Log the available routes for debugging
|
||||
log.info("Available routes in dashboard_api_app:")
|
||||
for route in dashboard_api_app.routes:
|
||||
|
335
api_service/webhook_endpoints.py
Normal file
335
api_service/webhook_endpoints.py
Normal file
@ -0,0 +1,335 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Depends, Header, Path
|
||||
import discord # For Color
|
||||
|
||||
# Assuming settings_manager and api_server are accessible
|
||||
# Adjust imports based on your project structure
|
||||
try:
|
||||
from .. import settings_manager # If api_service is a package
|
||||
from .api_server import send_discord_message_via_api, get_api_settings # For settings
|
||||
except ImportError:
|
||||
# This path might be used if running webhook_endpoints.py directly for testing (not typical for FastAPI)
|
||||
# Or if the structure is flat
|
||||
import settings_manager
|
||||
# If api_server.py is in the same directory:
|
||||
from api_server import send_discord_message_via_api, get_api_settings
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
api_settings = get_api_settings() # Get loaded API settings
|
||||
|
||||
def verify_github_signature(payload_body: bytes, secret_token: str, signature_header: str) -> bool:
|
||||
"""Verify that the payload was sent from GitHub by validating the signature."""
|
||||
if not signature_header:
|
||||
log.warning("No X-Hub-Signature-256 found on request.")
|
||||
return False
|
||||
if not secret_token:
|
||||
log.error("Webhook secret is not configured for this repository. Cannot verify signature.")
|
||||
return False
|
||||
|
||||
hash_object = hmac.new(secret_token.encode('utf-8'), msg=payload_body, digestmod=hashlib.sha256)
|
||||
expected_signature = "sha256=" + hash_object.hexdigest()
|
||||
if not hmac.compare_digest(expected_signature, signature_header):
|
||||
log.warning(f"Request signature mismatch. Expected: {expected_signature}, Got: {signature_header}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def verify_gitlab_token(secret_token: str, gitlab_token_header: str) -> bool:
|
||||
"""Verify that the payload was sent from GitLab by validating the token."""
|
||||
if not gitlab_token_header:
|
||||
log.warning("No X-Gitlab-Token found on request.")
|
||||
return False
|
||||
if not secret_token:
|
||||
log.error("Webhook secret is not configured for this repository. Cannot verify token.")
|
||||
return False
|
||||
if not hmac.compare_digest(secret_token, gitlab_token_header): # Direct comparison for GitLab token
|
||||
log.warning("Request token mismatch.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def format_github_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed:
|
||||
"""Formats a GitHub push event payload into a Discord embed."""
|
||||
try:
|
||||
repo_name = payload.get('repository', {}).get('full_name', repo_url)
|
||||
pusher = payload.get('pusher', {}).get('name', 'Unknown Pusher')
|
||||
compare_url = payload.get('compare', repo_url)
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"New Push to {repo_name}",
|
||||
url=compare_url,
|
||||
color=discord.Color.blue() # Or discord.Color.from_rgb(r, g, b)
|
||||
)
|
||||
embed.set_author(name=pusher)
|
||||
|
||||
for commit in payload.get('commits', []):
|
||||
commit_id_short = commit.get('id', 'N/A')[:7]
|
||||
commit_msg = commit.get('message', 'No commit message.')
|
||||
commit_url = commit.get('url', '#')
|
||||
author_name = commit.get('author', {}).get('name', 'Unknown Author')
|
||||
|
||||
# Files changed, insertions/deletions
|
||||
added = commit.get('added', [])
|
||||
removed = commit.get('removed', [])
|
||||
modified = commit.get('modified', [])
|
||||
|
||||
stats_lines = []
|
||||
if added: stats_lines.append(f"+{len(added)} added")
|
||||
if removed: stats_lines.append(f"-{len(removed)} removed")
|
||||
if modified: stats_lines.append(f"~{len(modified)} modified")
|
||||
stats_str = ", ".join(stats_lines) if stats_lines else "No file changes."
|
||||
|
||||
# Verification status (GitHub specific)
|
||||
verification = commit.get('verification', {})
|
||||
verified_status = "Verified" if verification.get('verified') else "Unverified"
|
||||
if verification.get('reason') and verification.get('reason') != 'unsigned':
|
||||
verified_status += f" ({verification.get('reason')})"
|
||||
|
||||
|
||||
field_value = (
|
||||
f"Author: {author_name}\n"
|
||||
f"Message: {commit_msg.splitlines()[0]}\n" # First line of commit message
|
||||
f"Verification: {verified_status}\n"
|
||||
f"Stats: {stats_str}\n"
|
||||
f"[View Commit]({commit_url})"
|
||||
)
|
||||
embed.add_field(name=f"Commit `{commit_id_short}`", value=field_value, inline=False)
|
||||
if len(embed.fields) >= 5: # Limit fields to avoid overly large embeds
|
||||
embed.add_field(name="...", value=f"And {len(payload.get('commits')) - 5} more commits.", inline=False)
|
||||
break
|
||||
|
||||
if not payload.get('commits'):
|
||||
embed.description = "Received push event with no commits (e.g., new branch created without commits)."
|
||||
|
||||
return embed
|
||||
except Exception as e:
|
||||
log.exception(f"Error formatting GitHub embed: {e}")
|
||||
embed = discord.Embed(title="Error Processing GitHub Webhook", description=f"Could not parse commit details. Raw payload might be available in logs.\nError: {e}", color=discord.Color.red())
|
||||
return embed
|
||||
|
||||
|
||||
def format_gitlab_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed:
|
||||
"""Formats a GitLab push event payload into a Discord embed."""
|
||||
try:
|
||||
project_name = payload.get('project', {}).get('path_with_namespace', repo_url)
|
||||
user_name = payload.get('user_name', 'Unknown Pusher')
|
||||
|
||||
# GitLab's compare URL is not directly in the main payload, but commits have URLs
|
||||
# We can use the project's web_url as a base.
|
||||
project_web_url = payload.get('project', {}).get('web_url', repo_url)
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"New Push to {project_name}",
|
||||
url=project_web_url, # Link to project
|
||||
color=discord.Color.orange() # Or discord.Color.from_rgb(r, g, b)
|
||||
)
|
||||
embed.set_author(name=user_name)
|
||||
|
||||
for commit in payload.get('commits', []):
|
||||
commit_id_short = commit.get('id', 'N/A')[:7]
|
||||
commit_msg = commit.get('message', 'No commit message.')
|
||||
commit_url = commit.get('url', '#')
|
||||
author_name = commit.get('author', {}).get('name', 'Unknown Author')
|
||||
|
||||
# Files changed, insertions/deletions (GitLab provides total counts)
|
||||
# GitLab commit objects don't directly list added/removed/modified files in the same way GitHub does per commit in a push.
|
||||
# The overall push event has 'total_commits_count', but individual commit stats are usually fetched separately if needed.
|
||||
# For simplicity, we'll list files if available, or just the message.
|
||||
stats_lines = []
|
||||
# GitLab's commit object in webhook doesn't typically include detailed file stats like GitHub's.
|
||||
# It might have 'added', 'modified', 'removed' at the top level of the push event for the whole push, not per commit.
|
||||
# We'll focus on commit message and author for now.
|
||||
|
||||
# GitLab commit verification is not as straightforward in the webhook payload as GitHub's.
|
||||
# It's often handled via GPG keys and displayed in the UI. We'll omit for now.
|
||||
|
||||
field_value = (
|
||||
f"Author: {author_name}\n"
|
||||
f"Message: {commit_msg.splitlines()[0]}\n" # First line
|
||||
f"[View Commit]({commit_url})"
|
||||
)
|
||||
embed.add_field(name=f"Commit `{commit_id_short}`", value=field_value, inline=False)
|
||||
if len(embed.fields) >= 5:
|
||||
embed.add_field(name="...", value=f"And {len(payload.get('commits')) - 5} more commits.", inline=False)
|
||||
break
|
||||
|
||||
if not payload.get('commits'):
|
||||
embed.description = "Received push event with no commits (e.g., new branch created or tag pushed)."
|
||||
|
||||
return embed
|
||||
except Exception as e:
|
||||
log.exception(f"Error formatting GitLab embed: {e}")
|
||||
embed = discord.Embed(title="Error Processing GitLab Webhook", description=f"Could not parse commit details. Raw payload might be available in logs.\nError: {e}", color=discord.Color.red())
|
||||
return embed
|
||||
|
||||
|
||||
@router.post("/webhook/github/{repo_db_id}")
|
||||
async def webhook_github(
|
||||
request: Request,
|
||||
repo_db_id: int = Path(..., description="The database ID of the monitored repository"),
|
||||
x_hub_signature_256: Optional[str] = Header(None)
|
||||
):
|
||||
log.info(f"Received GitHub webhook for repo_db_id: {repo_db_id}")
|
||||
payload_bytes = await request.body()
|
||||
|
||||
repo_config = await settings_manager.get_monitored_repository_by_id(repo_db_id)
|
||||
if not repo_config:
|
||||
log.error(f"No repository configuration found for repo_db_id: {repo_db_id}")
|
||||
raise HTTPException(status_code=404, detail="Repository configuration not found.")
|
||||
|
||||
if repo_config['monitoring_method'] != 'webhook' or repo_config['platform'] != 'github':
|
||||
log.error(f"Repository {repo_db_id} is not configured for GitHub webhooks.")
|
||||
raise HTTPException(status_code=400, detail="Repository not configured for GitHub webhooks.")
|
||||
|
||||
if not verify_github_signature(payload_bytes, repo_config['webhook_secret'], x_hub_signature_256):
|
||||
log.warning(f"Invalid GitHub signature for repo_db_id: {repo_db_id}")
|
||||
raise HTTPException(status_code=403, detail="Invalid signature.")
|
||||
|
||||
try:
|
||||
payload = json.loads(payload_bytes.decode('utf-8'))
|
||||
except json.JSONDecodeError:
|
||||
log.error(f"Invalid JSON payload received for GitHub webhook, repo_db_id: {repo_db_id}")
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON payload.")
|
||||
|
||||
log.debug(f"GitHub webhook payload for {repo_db_id}: {payload}")
|
||||
|
||||
# We only care about 'push' events for commits
|
||||
event_type = request.headers.get("X-GitHub-Event")
|
||||
if event_type != "push":
|
||||
log.info(f"Ignoring GitHub event type '{event_type}' for repo_db_id: {repo_db_id}")
|
||||
return {"status": "success", "message": f"Event type '{event_type}' ignored."}
|
||||
|
||||
if not payload.get('commits'):
|
||||
log.info(f"GitHub push event for {repo_db_id} has no commits (e.g. branch creation/deletion). Ignoring.")
|
||||
return {"status": "success", "message": "Push event with no commits ignored."}
|
||||
|
||||
notification_channel_id = repo_config['notification_channel_id']
|
||||
discord_embed = format_github_embed(payload, repo_config['repository_url'])
|
||||
|
||||
# Convert embed to dict for sending via API
|
||||
message_content = {"embeds": [discord_embed.to_dict()]}
|
||||
|
||||
# Use the send_discord_message_via_api from api_server.py
|
||||
# This requires DISCORD_BOT_TOKEN to be set in the environment for api_server
|
||||
if not api_settings.DISCORD_BOT_TOKEN:
|
||||
log.error("DISCORD_BOT_TOKEN not configured in API settings. Cannot send webhook notification.")
|
||||
# Still return 200 to GitHub to acknowledge receipt, but log error.
|
||||
return {"status": "error", "message": "Notification sending failed (bot token not configured)."}
|
||||
|
||||
send_result = await send_discord_message_via_api(
|
||||
channel_id=notification_channel_id,
|
||||
content=json.dumps(message_content) # send_discord_message_via_api expects a string for 'content'
|
||||
# but it should handle dicts with 'embeds' if modified or we send raw.
|
||||
# For now, let's assume it needs a simple string or we adapt it.
|
||||
# The current send_discord_message_via_api sends 'content' as a top-level string.
|
||||
# We need to send an embed.
|
||||
)
|
||||
# The send_discord_message_via_api needs to be adapted to send embeds.
|
||||
# For now, let's construct the data for the POST request directly as it would expect.
|
||||
|
||||
# Corrected way to send embed using the existing send_discord_message_via_api structure
|
||||
# The function expects a simple string content. We need to modify it or use aiohttp directly here.
|
||||
# Let's assume we'll modify send_discord_message_via_api later or use a more direct aiohttp call.
|
||||
# For now, this will likely fail to send an embed correctly with the current send_discord_message_via_api.
|
||||
# This is a placeholder for correct embed sending.
|
||||
|
||||
# To send an embed, the JSON body to Discord API should be like:
|
||||
# { "embeds": [ { ... embed object ... } ] }
|
||||
# The current `send_discord_message_via_api` sends `{"content": "message"}`.
|
||||
# This part needs careful implementation.
|
||||
|
||||
# For now, let's log what would be sent.
|
||||
log.info(f"Prepared to send GitHub notification to channel {notification_channel_id} for repo {repo_db_id}.")
|
||||
# Actual sending logic will be refined.
|
||||
|
||||
# Placeholder for actual sending:
|
||||
# For a quick test, we can try to send a simple text message.
|
||||
# simple_text = f"New push to {repo_config['repository_url']}. Commits: {len(payload.get('commits', []))}"
|
||||
# send_result = await send_discord_message_via_api(notification_channel_id, simple_text)
|
||||
|
||||
# If send_discord_message_via_api is adapted to handle embeds in its 'content' (e.g. by checking if it's a dict with 'embeds' key)
|
||||
# then the following would be more appropriate:
|
||||
send_payload = {"embeds": [discord_embed.to_dict()]}
|
||||
# This requires send_discord_message_via_api to be flexible.
|
||||
send_payload_dict = {"embeds": [discord_embed.to_dict()]}
|
||||
|
||||
send_result = await send_discord_message_via_api(
|
||||
channel_id=notification_channel_id,
|
||||
content=send_payload_dict # Pass the dict directly
|
||||
)
|
||||
|
||||
if send_result.get("success"):
|
||||
log.info(f"Successfully sent GitHub webhook notification for repo {repo_db_id} to channel {notification_channel_id}.")
|
||||
return {"status": "success", "message": "Webhook received and notification sent."}
|
||||
else:
|
||||
log.error(f"Failed to send GitHub webhook notification for repo {repo_db_id}. Error: {send_result.get('message')}")
|
||||
# Still return 200 to GitHub to acknowledge receipt, but log the internal failure.
|
||||
return {"status": "error", "message": f"Webhook received, but notification failed: {send_result.get('message')}"}
|
||||
|
||||
|
||||
@router.post("/webhook/gitlab/{repo_db_id}")
|
||||
async def webhook_gitlab(
|
||||
request: Request,
|
||||
repo_db_id: int = Path(..., description="The database ID of the monitored repository"),
|
||||
x_gitlab_token: Optional[str] = Header(None)
|
||||
):
|
||||
log.info(f"Received GitLab webhook for repo_db_id: {repo_db_id}")
|
||||
payload_bytes = await request.body()
|
||||
|
||||
repo_config = await settings_manager.get_monitored_repository_by_id(repo_db_id)
|
||||
if not repo_config:
|
||||
log.error(f"No repository configuration found for repo_db_id: {repo_db_id}")
|
||||
raise HTTPException(status_code=404, detail="Repository configuration not found.")
|
||||
|
||||
if repo_config['monitoring_method'] != 'webhook' or repo_config['platform'] != 'gitlab':
|
||||
log.error(f"Repository {repo_db_id} is not configured for GitLab webhooks.")
|
||||
raise HTTPException(status_code=400, detail="Repository not configured for GitLab webhooks.")
|
||||
|
||||
if not verify_gitlab_token(repo_config['webhook_secret'], x_gitlab_token):
|
||||
log.warning(f"Invalid GitLab token for repo_db_id: {repo_db_id}")
|
||||
raise HTTPException(status_code=403, detail="Invalid token.")
|
||||
|
||||
try:
|
||||
payload = json.loads(payload_bytes.decode('utf-8'))
|
||||
except json.JSONDecodeError:
|
||||
log.error(f"Invalid JSON payload received for GitLab webhook, repo_db_id: {repo_db_id}")
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON payload.")
|
||||
|
||||
log.debug(f"GitLab webhook payload for {repo_db_id}: {payload}")
|
||||
|
||||
# GitLab uses 'object_kind' for event type
|
||||
event_type = payload.get("object_kind")
|
||||
if event_type != "push": # GitLab calls it 'push' for push hooks
|
||||
log.info(f"Ignoring GitLab event type '{event_type}' for repo_db_id: {repo_db_id}")
|
||||
return {"status": "success", "message": f"Event type '{event_type}' ignored."}
|
||||
|
||||
if not payload.get('commits'):
|
||||
log.info(f"GitLab push event for {repo_db_id} has no commits. Ignoring.")
|
||||
return {"status": "success", "message": "Push event with no commits ignored."}
|
||||
|
||||
notification_channel_id = repo_config['notification_channel_id']
|
||||
discord_embed = format_gitlab_embed(payload, repo_config['repository_url'])
|
||||
|
||||
# Similar to GitHub, sending embed needs careful handling with send_discord_message_via_api
|
||||
if not api_settings.DISCORD_BOT_TOKEN:
|
||||
log.error("DISCORD_BOT_TOKEN not configured in API settings. Cannot send webhook notification.")
|
||||
return {"status": "error", "message": "Notification sending failed (bot token not configured)."}
|
||||
|
||||
send_payload_dict = {"embeds": [discord_embed.to_dict()]}
|
||||
|
||||
send_result = await send_discord_message_via_api(
|
||||
channel_id=notification_channel_id,
|
||||
content=send_payload_dict # Pass the dict directly
|
||||
)
|
||||
|
||||
if send_result.get("success"):
|
||||
log.info(f"Successfully sent GitLab webhook notification for repo {repo_db_id} to channel {notification_channel_id}.")
|
||||
return {"status": "success", "message": "Webhook received and notification sent."}
|
||||
else:
|
||||
log.error(f"Failed to send GitLab webhook notification for repo {repo_db_id}. Error: {send_result.get('message')}")
|
||||
return {"status": "error", "message": f"Webhook received, but notification failed: {send_result.get('message')}"}
|
401
cogs/git_monitor_cog.py
Normal file
401
cogs/git_monitor_cog.py
Normal file
@ -0,0 +1,401 @@
|
||||
import discord
|
||||
from discord.ext import commands, tasks
|
||||
from discord import app_commands
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import datetime # Added for timezone.utc
|
||||
from typing import Literal, Optional, List, Dict, Any
|
||||
import asyncio # For sleep
|
||||
import aiohttp # For API calls
|
||||
import requests.utils # For url encoding gitlab project path
|
||||
|
||||
# Assuming settings_manager is in the parent directory
|
||||
# Adjust the import path if your project structure is different
|
||||
try:
|
||||
from .. import settings_manager # If cogs is a package
|
||||
except ImportError:
|
||||
import settings_manager # If run from the root or cogs is not a package
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Helper to parse repo URL and determine platform
|
||||
def parse_repo_url(url: str) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Parses a Git repository URL to extract platform and a simplified repo identifier."""
|
||||
github_match = re.match(r"https^(?:https?://)?(?:www\.)?github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$", url)
|
||||
if github_match:
|
||||
return "github", github_match.group(1)
|
||||
|
||||
gitlab_match = re.match(r"^(?:https?://)?(?:www\.)?gitlab\.com/([\w.-]+(?:/[\w.-]+)+?)(?:\.git)?/?$", url)
|
||||
if gitlab_match:
|
||||
return "gitlab", gitlab_match.group(1)
|
||||
return None, None
|
||||
|
||||
|
||||
class GitMonitorCog(commands.Cog):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self.poll_repositories_task.start()
|
||||
log.info("GitMonitorCog initialized and polling task started.")
|
||||
|
||||
def cog_unload(self):
|
||||
self.poll_repositories_task.cancel()
|
||||
log.info("GitMonitorCog unloaded and polling task cancelled.")
|
||||
|
||||
@tasks.loop(minutes=5.0) # Default, can be adjusted or made dynamic later
|
||||
async def poll_repositories_task(self):
|
||||
log.debug("Git repository polling task running...")
|
||||
try:
|
||||
repos_to_poll = await settings_manager.get_all_repositories_for_polling()
|
||||
if not repos_to_poll:
|
||||
log.debug("No repositories configured for polling.")
|
||||
return
|
||||
|
||||
log.info(f"Found {len(repos_to_poll)} repositories to poll.")
|
||||
|
||||
for repo_config in repos_to_poll:
|
||||
repo_id = repo_config['id']
|
||||
guild_id = repo_config['guild_id']
|
||||
repo_url = repo_config['repository_url']
|
||||
platform = repo_config['platform']
|
||||
channel_id = repo_config['notification_channel_id']
|
||||
target_branch = repo_config['target_branch'] # Get the target branch
|
||||
last_sha = repo_config['last_polled_commit_sha']
|
||||
# polling_interval = repo_config['polling_interval_minutes'] # Use this if intervals are dynamic per repo
|
||||
|
||||
log.debug(f"Polling {platform} repo: {repo_url} (Branch: {target_branch or 'default'}) (ID: {repo_id}) for guild {guild_id}")
|
||||
|
||||
new_commits_data: List[Dict[str, Any]] = []
|
||||
latest_fetched_sha = last_sha
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(headers={"User-Agent": "DiscordBot/1.0"}) as session:
|
||||
if platform == "github":
|
||||
# GitHub API: GET /repos/{owner}/{repo}/commits
|
||||
# We need to parse owner/repo from repo_url
|
||||
_, owner_repo_path = parse_repo_url(repo_url) # e.g. "user/repo"
|
||||
if owner_repo_path:
|
||||
api_url = f"https://api.github.com/repos/{owner_repo_path}/commits"
|
||||
params = {"per_page": 10} # Fetch up to 10 recent commits
|
||||
if target_branch:
|
||||
params["sha"] = target_branch # GitHub uses 'sha' for branch/tag/commit SHA
|
||||
# No 'since_sha' for GitHub commits list. Manual filtering after fetch.
|
||||
|
||||
async with session.get(api_url, params=params) as response:
|
||||
if response.status == 200:
|
||||
commits_payload = await response.json()
|
||||
temp_new_commits = []
|
||||
for commit_item in reversed(commits_payload): # Process oldest first
|
||||
if commit_item['sha'] == last_sha:
|
||||
temp_new_commits = [] # Clear previous if we found the last one
|
||||
continue
|
||||
temp_new_commits.append(commit_item)
|
||||
|
||||
if temp_new_commits:
|
||||
new_commits_data = temp_new_commits
|
||||
latest_fetched_sha = new_commits_data[-1]['sha']
|
||||
elif response.status == 403: # Rate limit
|
||||
log.warning(f"GitHub API rate limit hit for {repo_url}. Headers: {response.headers}")
|
||||
# Consider increasing loop wait time or specific backoff for this repo
|
||||
elif response.status == 404:
|
||||
log.error(f"Repository {repo_url} not found on GitHub (404). Consider removing or marking as invalid.")
|
||||
else:
|
||||
log.error(f"Error fetching GitHub commits for {repo_url}: {response.status} - {await response.text()}")
|
||||
|
||||
elif platform == "gitlab":
|
||||
# GitLab API: GET /projects/{id}/repository/commits
|
||||
# We need project ID or URL-encoded path.
|
||||
_, project_path = parse_repo_url(repo_url) # e.g. "group/subgroup/project"
|
||||
if project_path:
|
||||
encoded_project_path = requests.utils.quote(project_path, safe='')
|
||||
api_url = f"https://gitlab.com/api/v4/projects/{encoded_project_path}/repository/commits"
|
||||
params = {"per_page": 10}
|
||||
if target_branch:
|
||||
params["ref_name"] = target_branch # GitLab uses 'ref_name' for branch/tag
|
||||
# No 'since_sha' for GitLab. Manual filtering.
|
||||
|
||||
async with session.get(api_url, params=params) as response:
|
||||
if response.status == 200:
|
||||
commits_payload = await response.json()
|
||||
temp_new_commits = []
|
||||
for commit_item in reversed(commits_payload):
|
||||
if commit_item['id'] == last_sha:
|
||||
temp_new_commits = []
|
||||
continue
|
||||
temp_new_commits.append(commit_item)
|
||||
|
||||
if temp_new_commits:
|
||||
new_commits_data = temp_new_commits
|
||||
latest_fetched_sha = new_commits_data[-1]['id']
|
||||
elif response.status == 403:
|
||||
log.warning(f"GitLab API rate limit hit for {repo_url}. Headers: {response.headers}")
|
||||
elif response.status == 404:
|
||||
log.error(f"Repository {repo_url} not found on GitLab (404).")
|
||||
else:
|
||||
log.error(f"Error fetching GitLab commits for {repo_url}: {response.status} - {await response.text()}")
|
||||
except aiohttp.ClientError as ce:
|
||||
log.error(f"AIOHTTP client error polling {repo_url}: {ce}")
|
||||
except Exception as ex:
|
||||
log.exception(f"Generic error polling {repo_url}: {ex}")
|
||||
|
||||
|
||||
if new_commits_data:
|
||||
channel = self.bot.get_channel(channel_id)
|
||||
if channel:
|
||||
for commit_item_data in new_commits_data:
|
||||
embed = None
|
||||
if platform == "github":
|
||||
commit_sha = commit_item_data.get('sha', 'N/A')
|
||||
commit_id_short = commit_sha[:7]
|
||||
commit_data = commit_item_data.get('commit', {})
|
||||
commit_msg = commit_data.get('message', 'No message.')
|
||||
commit_url = commit_item_data.get('html_url', '#')
|
||||
author_info = commit_data.get('author', {}) # Committer info is also available
|
||||
author_name = author_info.get('name', 'Unknown Author')
|
||||
# Branch information is not directly available in this specific commit object from /commits endpoint.
|
||||
# It's part of the push event or needs to be inferred/fetched differently for polling.
|
||||
# For polling, we typically monitor a specific branch, or assume default.
|
||||
# Verification status
|
||||
verification = commit_data.get('verification', {})
|
||||
verified_status = "Verified" if verification.get('verified') else "Unverified"
|
||||
if verification.get('reason') and verification.get('reason') != 'unsigned':
|
||||
verified_status += f" ({verification.get('reason')})"
|
||||
|
||||
# Files changed and stats require another API call per commit: GET /repos/{owner}/{repo}/commits/{sha}
|
||||
# This is too API intensive for a simple polling loop.
|
||||
# We will omit detailed file stats for polled GitHub commits for now.
|
||||
files_changed_str = "File stats not fetched for polled commits."
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"New Commit in {repo_url}",
|
||||
description=commit_msg.splitlines()[0], # First line
|
||||
color=discord.Color.blue(),
|
||||
url=commit_url
|
||||
)
|
||||
embed.set_author(name=author_name)
|
||||
embed.add_field(name="Commit", value=f"[`{commit_id_short}`]({commit_url})", inline=True)
|
||||
embed.add_field(name="Verification", value=verified_status, inline=True)
|
||||
# embed.add_field(name="Branch", value="default (polling)", inline=True) # Placeholder
|
||||
embed.add_field(name="Changes", value=files_changed_str, inline=False)
|
||||
|
||||
elif platform == "gitlab":
|
||||
commit_id = commit_item_data.get('id', 'N/A')
|
||||
commit_id_short = commit_item_data.get('short_id', commit_id[:7])
|
||||
commit_msg = commit_item_data.get('title', 'No message.') # GitLab uses 'title' for first line
|
||||
commit_url = commit_item_data.get('web_url', '#')
|
||||
author_name = commit_item_data.get('author_name', 'Unknown Author')
|
||||
# Branch information is not directly in this commit object from /commits.
|
||||
# It's part of the push event or needs to be inferred.
|
||||
# GitLab commit stats (added/deleted lines) are in the commit details, not list.
|
||||
files_changed_str = "File stats not fetched for polled commits."
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"New Commit in {repo_url}",
|
||||
description=commit_msg.splitlines()[0],
|
||||
color=discord.Color.orange(),
|
||||
url=commit_url
|
||||
)
|
||||
embed.set_author(name=author_name)
|
||||
embed.add_field(name="Commit", value=f"[`{commit_id_short}`]({commit_url})", inline=True)
|
||||
# embed.add_field(name="Branch", value="default (polling)", inline=True) # Placeholder
|
||||
embed.add_field(name="Changes", value=files_changed_str, inline=False)
|
||||
|
||||
if embed:
|
||||
try:
|
||||
await channel.send(embed=embed)
|
||||
log.info(f"Sent polled notification for commit {commit_id_short} in {repo_url} to channel {channel_id}")
|
||||
except discord.Forbidden:
|
||||
log.error(f"Missing permissions to send message in channel {channel_id} for guild {guild_id}")
|
||||
except discord.HTTPException as dhe:
|
||||
log.error(f"Discord HTTP error sending message for {repo_url}: {dhe}")
|
||||
else:
|
||||
log.warning(f"Channel {channel_id} not found for guild {guild_id} for repo {repo_url}")
|
||||
|
||||
# Update polling status in DB
|
||||
if latest_fetched_sha != last_sha or not new_commits_data : # Update if new sha or just to update timestamp
|
||||
await settings_manager.update_repository_polling_status(repo_id, latest_fetched_sha, datetime.datetime.now(datetime.timezone.utc))
|
||||
|
||||
# Small delay between processing each repo to be nice to APIs
|
||||
await asyncio.sleep(2) # 2 seconds delay
|
||||
|
||||
except Exception as e:
|
||||
log.exception("Error occurred during repository polling task:", exc_info=e)
|
||||
|
||||
@poll_repositories_task.before_loop
|
||||
async def before_poll_repositories_task(self):
|
||||
await self.bot.wait_until_ready()
|
||||
log.info("Polling task is waiting for bot to be ready...")
|
||||
|
||||
gitlistener_group = app_commands.Group(name="gitlistener", description="Manage Git repository monitoring.")
|
||||
|
||||
@gitlistener_group.command(name="add", description="Add a repository to monitor for commits.")
|
||||
@app_commands.describe(
|
||||
repository_url="The full URL of the GitHub or GitLab repository (e.g., https://github.com/user/repo).",
|
||||
channel="The channel where commit notifications should be sent.",
|
||||
monitoring_method="Choose 'webhook' for real-time (requires repo admin rights) or 'poll' for periodic checks.",
|
||||
branch="The specific branch to monitor (for 'poll' method, defaults to main/master if not specified)."
|
||||
)
|
||||
@app_commands.checks.has_permissions(manage_guild=True)
|
||||
async def add_repository(self, interaction: discord.Interaction,
|
||||
repository_url: str,
|
||||
channel: discord.TextChannel,
|
||||
monitoring_method: Literal['webhook', 'poll'],
|
||||
branch: Optional[str] = None):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
if monitoring_method == 'poll' and not branch:
|
||||
log.info(f"Branch not specified for polling method for {repository_url}. Will use default in polling task or API default.")
|
||||
# If branch is None, the polling task will attempt to use the repo's default branch.
|
||||
pass
|
||||
|
||||
platform, repo_identifier = parse_repo_url(repository_url)
|
||||
if not platform or not repo_identifier:
|
||||
await interaction.followup.send("Invalid repository URL. Please provide a valid GitHub or GitLab URL.", ephemeral=True)
|
||||
return
|
||||
|
||||
guild_id = interaction.guild_id
|
||||
added_by_user_id = interaction.user.id
|
||||
notification_channel_id = channel.id
|
||||
|
||||
# Check if this exact repo and channel combination already exists
|
||||
existing_config = await settings_manager.get_monitored_repository_by_url(guild_id, repository_url, notification_channel_id)
|
||||
if existing_config:
|
||||
await interaction.followup.send(f"This repository ({repository_url}) is already being monitored in {channel.mention}.", ephemeral=True)
|
||||
return
|
||||
|
||||
webhook_secret = None
|
||||
db_repo_id = None
|
||||
reply_message = ""
|
||||
|
||||
if monitoring_method == 'webhook':
|
||||
webhook_secret = secrets.token_hex(32)
|
||||
# The API server needs the bot's domain. This should be configured.
|
||||
# For now, we'll use a placeholder.
|
||||
# TODO: Fetch API base URL from config or bot instance
|
||||
api_base_url = getattr(self.bot, 'config', {}).get('API_BASE_URL', 'YOUR_API_DOMAIN_HERE.com')
|
||||
if api_base_url == 'YOUR_API_DOMAIN_HERE.com':
|
||||
log.warning("API_BASE_URL not configured for webhook URL generation. Using placeholder.")
|
||||
|
||||
|
||||
db_repo_id = await settings_manager.add_monitored_repository(
|
||||
guild_id=guild_id, repository_url=repository_url, platform=platform,
|
||||
monitoring_method='webhook', notification_channel_id=notification_channel_id,
|
||||
added_by_user_id=added_by_user_id, webhook_secret=webhook_secret, target_branch=None # Branch not used for webhooks
|
||||
)
|
||||
if db_repo_id:
|
||||
payload_url = f"https://{api_base_url}/webhook/{platform}/{db_repo_id}"
|
||||
reply_message = (
|
||||
f"Webhook monitoring for `{repo_identifier}` ({platform.capitalize()}) added for {channel.mention}!\n\n"
|
||||
f"**Action Required:**\n"
|
||||
f"1. Go to your repository's settings: `{repository_url}/settings/hooks` (GitHub) or `{repository_url}/-/hooks` (GitLab).\n"
|
||||
f"2. Add a new webhook.\n"
|
||||
f" - **Payload URL:** `{payload_url}`\n"
|
||||
f" - **Content type:** `application/json`\n"
|
||||
f" - **Secret:** `{webhook_secret}`\n"
|
||||
f" - **Events:** Select 'Just the push event' (GitHub) or 'Push events' (GitLab).\n"
|
||||
f"3. Click 'Add webhook'."
|
||||
)
|
||||
else:
|
||||
reply_message = "Failed to add repository for webhook monitoring. It might already exist or there was a database error."
|
||||
|
||||
elif monitoring_method == 'poll':
|
||||
# For polling, we might want to fetch the latest commit SHA now to avoid initial old notifications
|
||||
# This is a placeholder; actual fetching needs platform-specific API calls
|
||||
initial_sha = None # TODO: Implement initial SHA fetch if desired
|
||||
db_repo_id = await settings_manager.add_monitored_repository(
|
||||
guild_id=guild_id, repository_url=repository_url, platform=platform,
|
||||
monitoring_method='poll', notification_channel_id=notification_channel_id,
|
||||
added_by_user_id=added_by_user_id, target_branch=branch, # Pass the branch for polling
|
||||
last_polled_commit_sha=initial_sha
|
||||
)
|
||||
if db_repo_id:
|
||||
branch_info = f"on branch `{branch}`" if branch else "on the default branch"
|
||||
reply_message = (
|
||||
f"Polling monitoring for `{repo_identifier}` ({platform.capitalize()}) {branch_info} added for {channel.mention}.\n"
|
||||
f"The bot will check for new commits periodically (around every 5-15 minutes)."
|
||||
)
|
||||
else:
|
||||
reply_message = "Failed to add repository for polling. It might already exist or there was a database error."
|
||||
|
||||
if db_repo_id:
|
||||
await interaction.followup.send(reply_message, ephemeral=True)
|
||||
else:
|
||||
await interaction.followup.send(reply_message or "An unexpected error occurred.", ephemeral=True)
|
||||
|
||||
|
||||
@gitlistener_group.command(name="remove", description="Remove a repository from monitoring.")
|
||||
@app_commands.describe(
|
||||
repository_url="The full URL of the repository to remove.",
|
||||
channel="The channel it's sending notifications to."
|
||||
)
|
||||
@app_commands.checks.has_permissions(manage_guild=True)
|
||||
async def remove_repository(self, interaction: discord.Interaction, repository_url: str, channel: discord.TextChannel):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
guild_id = interaction.guild_id
|
||||
notification_channel_id = channel.id
|
||||
|
||||
platform, repo_identifier = parse_repo_url(repository_url)
|
||||
if not platform: # repo_identifier can be None if URL is valid but not parsable to simple form
|
||||
await interaction.followup.send("Invalid repository URL provided.", ephemeral=True)
|
||||
return
|
||||
|
||||
success = await settings_manager.remove_monitored_repository(guild_id, repository_url, notification_channel_id)
|
||||
|
||||
if success:
|
||||
await interaction.followup.send(
|
||||
f"Successfully removed monitoring for `{repository_url}` from {channel.mention}.\n"
|
||||
f"If this was a webhook, remember to also delete the webhook from the repository settings on {platform.capitalize()}.",
|
||||
ephemeral=True
|
||||
)
|
||||
else:
|
||||
await interaction.followup.send(f"Could not find a monitoring setup for `{repository_url}` in {channel.mention} to remove, or a database error occurred.", ephemeral=True)
|
||||
|
||||
@gitlistener_group.command(name="list", description="List repositories currently being monitored in this server.")
|
||||
@app_commands.checks.has_permissions(manage_guild=True)
|
||||
async def list_repositories(self, interaction: discord.Interaction):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
guild_id = interaction.guild_id
|
||||
monitored_repos = await settings_manager.list_monitored_repositories_for_guild(guild_id)
|
||||
|
||||
if not monitored_repos:
|
||||
await interaction.followup.send("No repositories are currently being monitored in this server.", ephemeral=True)
|
||||
return
|
||||
|
||||
embed = discord.Embed(title=f"Monitored Repositories for {interaction.guild.name}", color=discord.Color.blue())
|
||||
|
||||
description_lines = []
|
||||
for repo in monitored_repos:
|
||||
channel = self.bot.get_channel(repo['notification_channel_id'])
|
||||
channel_mention = channel.mention if channel else f"ID: {repo['notification_channel_id']}"
|
||||
method = repo['monitoring_method'].capitalize()
|
||||
platform = repo['platform'].capitalize()
|
||||
|
||||
# Attempt to get a cleaner repo name if possible
|
||||
_, repo_name_simple = parse_repo_url(repo['repository_url'])
|
||||
display_name = repo_name_simple if repo_name_simple else repo['repository_url']
|
||||
|
||||
description_lines.append(
|
||||
f"**[{display_name}]({repo['repository_url']})**\n"
|
||||
f"- Platform: {platform}\n"
|
||||
f"- Method: {method}\n"
|
||||
f"- Channel: {channel_mention}\n"
|
||||
f"- DB ID: `{repo['id']}`"
|
||||
)
|
||||
|
||||
embed.description = "\n\n".join(description_lines)
|
||||
if len(embed.description) > 4000 : # Discord embed description limit
|
||||
embed.description = embed.description[:3990] + "\n... (list truncated)"
|
||||
|
||||
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
# Ensure settings_manager's pools are set if this cog is loaded after bot's setup_hook
|
||||
# This is more of a safeguard; ideally, pools are set before cogs are loaded.
|
||||
if settings_manager and not getattr(settings_manager, '_active_pg_pool', None):
|
||||
log.warning("GitMonitorCog: settings_manager pools might not be set. Attempting to ensure they are via bot instance.")
|
||||
# This relies on bot having pg_pool and redis_pool attributes set by its setup_hook
|
||||
# settings_manager.set_bot_pools(getattr(bot, 'pg_pool', None), getattr(bot, 'redis_pool', None))
|
||||
|
||||
await bot.add_cog(GitMonitorCog(bot))
|
||||
log.info("GitMonitorCog added to bot.")
|
@ -208,6 +208,97 @@ class GiveawaysCog(commands.Cog, name="Giveaways"):
|
||||
# async def list_giveaways_slash(self, interaction: discord.Interaction):
|
||||
# pass
|
||||
|
||||
@app_commands.command(name="grollmanual", description="Manually roll a winner from reactions on a specific message.")
|
||||
@app_commands.describe(
|
||||
message_id="The ID of the message to get reactions from.",
|
||||
winners="How many winners to pick? (default: 1)",
|
||||
emoji="Which emoji should be considered for entry? (default: 🎉)"
|
||||
)
|
||||
@app_commands.checks.has_permissions(manage_guild=True)
|
||||
async def manual_roll_giveaway_slash(self, interaction: discord.Interaction, message_id: str, winners: int = 1, emoji: str = "🎉"):
|
||||
"""Manually picks winner(s) from reactions on a given message."""
|
||||
if winners < 1:
|
||||
await interaction.response.send_message("Number of winners must be at least 1.", ephemeral=True)
|
||||
return
|
||||
|
||||
try:
|
||||
msg_id = int(message_id)
|
||||
except ValueError:
|
||||
await interaction.response.send_message("Invalid Message ID format. It should be a number.", ephemeral=True)
|
||||
return
|
||||
|
||||
await interaction.response.defer(ephemeral=True) # Acknowledge interaction
|
||||
|
||||
try:
|
||||
# Try to fetch the message from the current channel first, then any channel in the guild
|
||||
message_to_roll = None
|
||||
try:
|
||||
message_to_roll = await interaction.channel.fetch_message(msg_id)
|
||||
except discord.NotFound:
|
||||
# If not in current channel, search all text channels in the guild
|
||||
for channel in interaction.guild.text_channels:
|
||||
try:
|
||||
message_to_roll = await channel.fetch_message(msg_id)
|
||||
if message_to_roll:
|
||||
break # Found the message
|
||||
except discord.NotFound:
|
||||
continue # Not in this channel
|
||||
except discord.Forbidden:
|
||||
await interaction.followup.send(f"I don't have permissions to read messages in {channel.mention}. Cannot fetch message {msg_id}.", ephemeral=True)
|
||||
return
|
||||
|
||||
if not message_to_roll:
|
||||
await interaction.followup.send(f"Could not find message with ID `{msg_id}` in this server.", ephemeral=True)
|
||||
return
|
||||
|
||||
except discord.Forbidden:
|
||||
await interaction.followup.send(f"I don't have permissions to read message history in {interaction.channel.mention} to find message `{msg_id}`.", ephemeral=True)
|
||||
return
|
||||
except Exception as e:
|
||||
await interaction.followup.send(f"An unexpected error occurred while fetching the message: {e}", ephemeral=True)
|
||||
return
|
||||
|
||||
entrants = set()
|
||||
reaction_found = False
|
||||
for reaction in message_to_roll.reactions:
|
||||
if str(reaction.emoji) == emoji:
|
||||
reaction_found = True
|
||||
async for user in reaction.users():
|
||||
if not user.bot:
|
||||
entrants.add(user)
|
||||
break
|
||||
|
||||
if not reaction_found:
|
||||
await interaction.followup.send(f"No reactions found with the emoji {emoji} on message `{msg_id}`.", ephemeral=True)
|
||||
return
|
||||
|
||||
if not entrants:
|
||||
await interaction.followup.send(f"No valid (non-bot) users reacted with {emoji} on message `{msg_id}`.", ephemeral=True)
|
||||
return
|
||||
|
||||
winners_list = []
|
||||
if len(entrants) <= winners:
|
||||
winners_list = list(entrants)
|
||||
else:
|
||||
winners_list = random.sample(list(entrants), winners)
|
||||
|
||||
if winners_list:
|
||||
winner_mentions = ", ".join(w.mention for w in winners_list)
|
||||
# Announce in the channel where command was used, not necessarily message_to_roll.channel
|
||||
await interaction.followup.send(f"Congratulations {winner_mentions}! You've been manually selected as winner(s) from message `{msg_id}` in {message_to_roll.channel.mention}!", ephemeral=False)
|
||||
|
||||
# Optionally, also send to the original message's channel if different and bot has perms
|
||||
if interaction.channel.id != message_to_roll.channel.id:
|
||||
try:
|
||||
await message_to_roll.channel.send(f"Manual roll for message {message_to_roll.jump_url} concluded. Winner(s): {winner_mentions}")
|
||||
except discord.Forbidden:
|
||||
await interaction.followup.send(f"(Note: I couldn't announce the winner in {message_to_roll.channel.mention} due to missing permissions there.)", ephemeral=True)
|
||||
except discord.HTTPException:
|
||||
await interaction.followup.send(f"(Note: An error occurred trying to announce the winner in {message_to_roll.channel.mention}.)", ephemeral=True)
|
||||
|
||||
else: # Should not happen if entrants is not empty, but as a safeguard
|
||||
await interaction.followup.send(f"Could not select any winners from the reactions on message `{msg_id}`.", ephemeral=True)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(GiveawaysCog(bot))
|
||||
|
@ -225,6 +225,33 @@ async def initialize_database():
|
||||
);
|
||||
""")
|
||||
|
||||
# Git Monitored Repositories table
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS git_monitored_repositories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
guild_id BIGINT NOT NULL,
|
||||
repository_url TEXT NOT NULL,
|
||||
platform VARCHAR(10) NOT NULL CHECK (platform IN ('github', 'gitlab')),
|
||||
monitoring_method VARCHAR(10) NOT NULL CHECK (monitoring_method IN ('webhook', 'poll')),
|
||||
notification_channel_id BIGINT NOT NULL,
|
||||
webhook_secret TEXT, -- Nullable, only used for 'webhook' method
|
||||
target_branch VARCHAR(255), -- For polling: specific branch to monitor, null for default
|
||||
last_polled_commit_sha VARCHAR(64), -- Increased length for future-proofing
|
||||
last_polled_at TIMESTAMP WITH TIME ZONE,
|
||||
polling_interval_minutes INTEGER DEFAULT 15,
|
||||
is_public_repo BOOLEAN DEFAULT TRUE, -- Relevant for polling
|
||||
added_by_user_id BIGINT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_guild_repo_channel UNIQUE (guild_id, repository_url, notification_channel_id),
|
||||
FOREIGN KEY (guild_id) REFERENCES guilds(guild_id) ON DELETE CASCADE
|
||||
);
|
||||
""")
|
||||
# Add indexes for faster lookups
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_git_monitored_repo_guild ON git_monitored_repositories (guild_id);")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_git_monitored_repo_method ON git_monitored_repositories (monitoring_method);")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_git_monitored_repo_url ON git_monitored_repositories (repository_url);")
|
||||
|
||||
|
||||
# Logging Event Toggles table - Stores enabled/disabled state per event type
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS logging_event_toggles (
|
||||
@ -2151,3 +2178,198 @@ async def set_mod_log_channel_id(guild_id: int, channel_id: int | None) -> bool:
|
||||
# """Returns the active Redis pool instance."""
|
||||
# log.debug(f"get_redis_pool called. Returning _active_redis_pool with ID: {id(_active_redis_pool)}")
|
||||
# return _active_redis_pool
|
||||
|
||||
|
||||
# --- Git Repository Monitoring Functions ---
|
||||
|
||||
async def add_monitored_repository(
|
||||
guild_id: int,
|
||||
repository_url: str,
|
||||
platform: str, # 'github' or 'gitlab'
|
||||
monitoring_method: str, # 'webhook' or 'poll'
|
||||
notification_channel_id: int,
|
||||
added_by_user_id: int,
|
||||
webhook_secret: str | None = None, # Only for 'webhook'
|
||||
target_branch: str | None = None, # For polling
|
||||
polling_interval_minutes: int = 15,
|
||||
is_public_repo: bool = True,
|
||||
last_polled_commit_sha: str | None = None # For initial poll setup
|
||||
) -> int | None:
|
||||
"""Adds a new repository to monitor. Returns the ID of the new row, or None on failure."""
|
||||
bot = get_bot_instance()
|
||||
if not bot or not bot.pg_pool:
|
||||
log.error(f"Bot instance or PostgreSQL pool not available for add_monitored_repository (guild {guild_id}).")
|
||||
return None
|
||||
|
||||
try:
|
||||
async with bot.pg_pool.acquire() as conn:
|
||||
# Ensure guild exists
|
||||
await conn.execute("INSERT INTO guilds (guild_id) VALUES ($1) ON CONFLICT (guild_id) DO NOTHING;", guild_id)
|
||||
|
||||
# Insert the new repository monitoring entry
|
||||
repo_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO git_monitored_repositories (
|
||||
guild_id, repository_url, platform, monitoring_method,
|
||||
notification_channel_id, added_by_user_id, webhook_secret, target_branch,
|
||||
polling_interval_minutes, is_public_repo, last_polled_commit_sha
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (guild_id, repository_url, notification_channel_id) DO NOTHING
|
||||
RETURNING id;
|
||||
""",
|
||||
guild_id, repository_url, platform, monitoring_method,
|
||||
notification_channel_id, added_by_user_id, webhook_secret, target_branch,
|
||||
polling_interval_minutes, is_public_repo, last_polled_commit_sha
|
||||
)
|
||||
if repo_id:
|
||||
log.info(f"Added repository '{repository_url}' (Branch: {target_branch or 'default'}) for monitoring in guild {guild_id}, channel {notification_channel_id}. ID: {repo_id}")
|
||||
else:
|
||||
# This means ON CONFLICT DO NOTHING was triggered, fetch existing ID
|
||||
existing_id = await conn.fetchval(
|
||||
"""
|
||||
SELECT id FROM git_monitored_repositories
|
||||
WHERE guild_id = $1 AND repository_url = $2 AND notification_channel_id = $3;
|
||||
""",
|
||||
guild_id, repository_url, notification_channel_id
|
||||
)
|
||||
log.warning(f"Repository '{repository_url}' for guild {guild_id}, channel {notification_channel_id} already exists with ID {existing_id}. Not adding again.")
|
||||
return existing_id # Return existing ID if it was a conflict
|
||||
return repo_id
|
||||
except Exception as e:
|
||||
log.exception(f"Database error adding monitored repository '{repository_url}' for guild {guild_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_monitored_repository_by_id(repo_db_id: int) -> Dict | None:
|
||||
"""Gets details of a monitored repository by its database ID."""
|
||||
bot = get_bot_instance()
|
||||
if not bot or not bot.pg_pool:
|
||||
log.warning(f"Bot instance or PostgreSQL pool not available for get_monitored_repository_by_id (ID {repo_db_id}).")
|
||||
return None
|
||||
try:
|
||||
async with bot.pg_pool.acquire() as conn:
|
||||
record = await conn.fetchrow(
|
||||
"SELECT * FROM git_monitored_repositories WHERE id = $1",
|
||||
repo_db_id
|
||||
)
|
||||
return dict(record) if record else None
|
||||
except Exception as e:
|
||||
log.exception(f"Database error getting monitored repository by ID {repo_db_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_monitored_repository_by_url(guild_id: int, repository_url: str, notification_channel_id: int) -> Dict | None:
|
||||
"""Gets details of a monitored repository by its URL and channel for a specific guild."""
|
||||
bot = get_bot_instance()
|
||||
if not bot or not bot.pg_pool:
|
||||
log.warning(f"Bot instance or PostgreSQL pool not available for get_monitored_repository_by_url (guild {guild_id}).")
|
||||
return None
|
||||
try:
|
||||
async with bot.pg_pool.acquire() as conn:
|
||||
record = await conn.fetchrow(
|
||||
"""
|
||||
SELECT * FROM git_monitored_repositories
|
||||
WHERE guild_id = $1 AND repository_url = $2 AND notification_channel_id = $3
|
||||
""",
|
||||
guild_id, repository_url, notification_channel_id
|
||||
)
|
||||
return dict(record) if record else None
|
||||
except Exception as e:
|
||||
log.exception(f"Database error getting monitored repository by URL '{repository_url}' for guild {guild_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def update_repository_polling_status(repo_db_id: int, last_polled_commit_sha: str, last_polled_at: asyncio.Future | None = None) -> bool:
|
||||
"""Updates the last polled commit SHA and timestamp for a repository."""
|
||||
bot = get_bot_instance()
|
||||
if not bot or not bot.pg_pool:
|
||||
log.error(f"Bot instance or PostgreSQL pool not available for update_repository_polling_status (ID {repo_db_id}).")
|
||||
return False
|
||||
|
||||
# If last_polled_at is not provided, use current time
|
||||
current_time = last_polled_at if last_polled_at else datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
try:
|
||||
async with bot.pg_pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE git_monitored_repositories
|
||||
SET last_polled_commit_sha = $2, last_polled_at = $3
|
||||
WHERE id = $1;
|
||||
""",
|
||||
repo_db_id, last_polled_commit_sha, current_time
|
||||
)
|
||||
log.debug(f"Updated polling status for repository ID {repo_db_id} to SHA {last_polled_commit_sha[:7]}.")
|
||||
return True
|
||||
except Exception as e:
|
||||
log.exception(f"Database error updating polling status for repository ID {repo_db_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def remove_monitored_repository(guild_id: int, repository_url: str, notification_channel_id: int) -> bool:
|
||||
"""Removes a repository from monitoring for a specific guild and channel."""
|
||||
bot = get_bot_instance()
|
||||
if not bot or not bot.pg_pool:
|
||||
log.error(f"Bot instance or PostgreSQL pool not available for remove_monitored_repository (guild {guild_id}).")
|
||||
return False
|
||||
try:
|
||||
async with bot.pg_pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"""
|
||||
DELETE FROM git_monitored_repositories
|
||||
WHERE guild_id = $1 AND repository_url = $2 AND notification_channel_id = $3;
|
||||
""",
|
||||
guild_id, repository_url, notification_channel_id
|
||||
)
|
||||
# DELETE command returns a string like 'DELETE 1' if a row was deleted
|
||||
deleted_count = int(result.split()[-1]) if result.startswith("DELETE") else 0
|
||||
if deleted_count > 0:
|
||||
log.info(f"Removed repository '{repository_url}' from monitoring for guild {guild_id}, channel {notification_channel_id}.")
|
||||
return True
|
||||
else:
|
||||
log.warning(f"No repository '{repository_url}' found for monitoring in guild {guild_id}, channel {notification_channel_id} to remove.")
|
||||
return False
|
||||
except Exception as e:
|
||||
log.exception(f"Database error removing monitored repository '{repository_url}' for guild {guild_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def list_monitored_repositories_for_guild(guild_id: int) -> list[Dict]:
|
||||
"""Lists all repositories being monitored for a specific guild."""
|
||||
bot = get_bot_instance()
|
||||
if not bot or not bot.pg_pool:
|
||||
log.warning(f"Bot instance or PostgreSQL pool not available for list_monitored_repositories_for_guild (guild {guild_id}).")
|
||||
return []
|
||||
try:
|
||||
async with bot.pg_pool.acquire() as conn:
|
||||
records = await conn.fetch(
|
||||
"SELECT id, repository_url, platform, monitoring_method, notification_channel_id, created_at FROM git_monitored_repositories WHERE guild_id = $1 ORDER BY created_at DESC",
|
||||
guild_id
|
||||
)
|
||||
return [dict(record) for record in records]
|
||||
except Exception as e:
|
||||
log.exception(f"Database error listing monitored repositories for guild {guild_id}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def get_all_repositories_for_polling() -> list[Dict]:
|
||||
"""Fetches all repositories configured for polling."""
|
||||
bot = get_bot_instance()
|
||||
if not bot or not bot.pg_pool:
|
||||
log.warning("Bot instance or PostgreSQL pool not available for get_all_repositories_for_polling.")
|
||||
return []
|
||||
try:
|
||||
async with bot.pg_pool.acquire() as conn:
|
||||
records = await conn.fetch(
|
||||
"""
|
||||
SELECT id, guild_id, repository_url, platform, notification_channel_id, target_branch,
|
||||
last_polled_commit_sha, last_polled_at, polling_interval_minutes, is_public_repo
|
||||
FROM git_monitored_repositories
|
||||
WHERE monitoring_method = 'poll'
|
||||
ORDER BY guild_id, id;
|
||||
"""
|
||||
)
|
||||
return [dict(record) for record in records]
|
||||
except Exception as e:
|
||||
log.exception(f"Database error fetching all repositories for polling: {e}")
|
||||
return []
|
||||
|
Loading…
x
Reference in New Issue
Block a user