From 1efa9dc0528b2c8c9ed948f23ee0d78d4fb9456f Mon Sep 17 00:00:00 2001 From: Slipstream Date: Fri, 9 May 2025 18:48:16 -0600 Subject: [PATCH] aaa --- api_service/dashboard_api_endpoints.py | 98 ++++ .../dashboard_web/git-monitor-settings.html | 188 +++++++ api_service/webhook_endpoints.py | 499 +++++++++++++++++- settings_manager.py | 69 ++- 4 files changed, 822 insertions(+), 32 deletions(-) create mode 100644 api_service/dashboard_web/git-monitor-settings.html diff --git a/api_service/dashboard_api_endpoints.py b/api_service/dashboard_api_endpoints.py index d89a29b..0e7b3bb 100644 --- a/api_service/dashboard_api_endpoints.py +++ b/api_service/dashboard_api_endpoints.py @@ -1258,3 +1258,101 @@ async def get_conversation_messages( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error getting conversation messages: {str(e)}" ) + +# --- Git Monitor Webhook Event Configuration Endpoints --- + +class GitRepositoryEventSettings(BaseModel): + events: List[str] + +class AvailableGitEventsResponse(BaseModel): + platform: str + events: List[str] + +SUPPORTED_GITHUB_EVENTS = [ + "push", "issues", "issue_comment", "pull_request", "pull_request_review", + "pull_request_review_comment", "release", "fork", "star", "watch", + "commit_comment", "create", "delete", "deployment", "deployment_status", + "gollum", "member", "milestone", "project_card", "project_column", "project", + "public", "repository_dispatch", "status" + # Add more as needed/supported by formatters +] +SUPPORTED_GITLAB_EVENTS = [ + "push", "tag_push", "issues", "note", "merge_request", "wiki_page", + "pipeline", "job", "release" + # Add more as needed/supported by formatters + # GitLab uses "push_events", "issues_events" etc. in webhook config, + # but object_kind in payload is often singular like "push", "issue". + # We'll store and expect the singular/object_kind style. +] + +@router.get("/git_monitors/available_events/{platform}", response_model=AvailableGitEventsResponse) +async def get_available_git_events( + platform: str, + _user: dict = Depends(get_dashboard_user) # Basic auth to access +): + """Get a list of available/supported webhook event types for a given platform.""" + if platform == "github": + return AvailableGitEventsResponse(platform="github", events=SUPPORTED_GITHUB_EVENTS) + elif platform == "gitlab": + return AvailableGitEventsResponse(platform="gitlab", events=SUPPORTED_GITLAB_EVENTS) + else: + raise HTTPException(status_code=400, detail="Invalid platform specified. Use 'github' or 'gitlab'.") + + +@router.get("/guilds/{guild_id}/git_monitors/{repo_db_id}/events", response_model=GitRepositoryEventSettings) +async def get_git_repository_event_settings( + guild_id: int, # Added for verify_dashboard_guild_admin + repo_db_id: int, + _user: dict = Depends(get_dashboard_user), + _admin: bool = Depends(verify_dashboard_guild_admin) # Ensures user is admin of the guild +): + """Get the current allowed webhook events for a specific monitored repository.""" + try: + repo_config = await settings_manager.get_monitored_repository_by_id(repo_db_id) + if not repo_config: + raise HTTPException(status_code=404, detail="Monitored repository not found.") + if repo_config['guild_id'] != guild_id: # Ensure the repo belongs to the specified guild + raise HTTPException(status_code=403, detail="Repository does not belong to this guild.") + + allowed_events = repo_config.get('allowed_webhook_events', ['push']) # Default to ['push'] + return GitRepositoryEventSettings(events=allowed_events) + except HTTPException: + raise + except Exception as e: + log.error(f"Error getting git repository event settings for repo {repo_db_id}: {e}") + raise HTTPException(status_code=500, detail="Failed to retrieve repository event settings.") + +@router.put("/guilds/{guild_id}/git_monitors/{repo_db_id}/events", status_code=status.HTTP_200_OK) +async def update_git_repository_event_settings( + guild_id: int, # Added for verify_dashboard_guild_admin + repo_db_id: int, + settings: GitRepositoryEventSettings, + _user: dict = Depends(get_dashboard_user), + _admin: bool = Depends(verify_dashboard_guild_admin) # Ensures user is admin of the guild +): + """Update the allowed webhook events for a specific monitored repository.""" + try: + repo_config = await settings_manager.get_monitored_repository_by_id(repo_db_id) + if not repo_config: + raise HTTPException(status_code=404, detail="Monitored repository not found.") + if repo_config['guild_id'] != guild_id: # Ensure the repo belongs to the specified guild + raise HTTPException(status_code=403, detail="Repository does not belong to this guild.") + if repo_config['monitoring_method'] != 'webhook': + raise HTTPException(status_code=400, detail="Event settings are only applicable for webhook monitoring method.") + + # Validate events against supported list for the platform + platform = repo_config['platform'] + supported_events = SUPPORTED_GITHUB_EVENTS if platform == "github" else SUPPORTED_GITLAB_EVENTS + for event in settings.events: + if event not in supported_events: + raise HTTPException(status_code=400, detail=f"Event '{event}' is not supported for platform '{platform}'.") + + success = await settings_manager.update_monitored_repository_events(repo_db_id, settings.events) + if not success: + raise HTTPException(status_code=500, detail="Failed to update repository event settings.") + return {"message": "Repository event settings updated successfully."} + except HTTPException: + raise + except Exception as e: + log.error(f"Error updating git repository event settings for repo {repo_db_id}: {e}") + raise HTTPException(status_code=500, detail="Failed to update repository event settings.") diff --git a/api_service/dashboard_web/git-monitor-settings.html b/api_service/dashboard_web/git-monitor-settings.html new file mode 100644 index 0000000..58f67a7 --- /dev/null +++ b/api_service/dashboard_web/git-monitor-settings.html @@ -0,0 +1,188 @@ + + + + + + Git Monitor Event Settings + + + +
+

Git Monitor Event Settings

+
+

Repository: Loading...

+

Platform: Loading...

+
+ +
Loading event settings...
+ + +
+
+ + + + diff --git a/api_service/webhook_endpoints.py b/api_service/webhook_endpoints.py index 648ba6e..9adf3dc 100644 --- a/api_service/webhook_endpoints.py +++ b/api_service/webhook_endpoints.py @@ -106,6 +106,13 @@ async def get_monitored_repository_by_id_api(request: Request, repo_db_id: int) log.exception(f"Failed to create a new PostgreSQL connection after pool error: {e2}") return None +async def get_allowed_events_for_repo(request: Request, repo_db_id: int) -> list[str]: + """Helper to fetch allowed_webhook_events for a repo.""" + repo_config = await get_monitored_repository_by_id_api(request, repo_db_id) + if repo_config and repo_config.get('allowed_webhook_events'): + return repo_config['allowed_webhook_events'] + return ['push'] # Default to 'push' if not set or not found, for safety + 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: @@ -135,7 +142,12 @@ def verify_gitlab_token(secret_token: str, gitlab_token_header: str) -> bool: return False return True -def format_github_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed: +# Placeholder for other GitHub event formatters +# def format_github_issue_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed: ... +# def format_github_pull_request_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed: ... +# def format_github_release_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed: ... + +def format_github_push_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) @@ -190,12 +202,16 @@ def format_github_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed 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()) + log.exception(f"Error formatting GitHub push embed: {e}") + embed = discord.Embed(title="Error Processing GitHub Push Webhook", description=f"Could not parse commit details. Raw payload might be available in logs.\nError: {e}", color=discord.Color.red()) return embed +# Placeholder for other GitLab event formatters +# def format_gitlab_issue_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed: ... +# def format_gitlab_merge_request_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed: ... +# def format_gitlab_tag_push_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed: ... -def format_gitlab_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed: +def format_gitlab_push_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) @@ -244,10 +260,361 @@ def format_gitlab_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed 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()) + log.exception(f"Error formatting GitLab push embed: {e}") + embed = discord.Embed(title="Error Processing GitLab Push Webhook", description=f"Could not parse commit details. Raw payload might be available in logs.\nError: {e}", color=discord.Color.red()) return embed +# --- GitHub - New Event Formatters --- + +def format_github_issues_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed: + """Formats a Discord embed for a GitHub issues event.""" + try: + action = payload.get('action', 'Unknown action') + issue_data = payload.get('issue', {}) + repo_name = payload.get('repository', {}).get('full_name', repo_url) + sender = payload.get('sender', {}) + + title = issue_data.get('title', 'Untitled Issue') + issue_number = issue_data.get('number') + issue_url = issue_data.get('html_url', repo_url) + user_login = sender.get('login', 'Unknown User') + user_url = sender.get('html_url', '#') + user_avatar = sender.get('avatar_url') + + color = discord.Color.green() if action == "opened" else \ + discord.Color.red() if action == "closed" else \ + discord.Color.gold() if action == "reopened" else \ + discord.Color.light_grey() + + embed = discord.Embed( + title=f"Issue {action.capitalize()}: #{issue_number} {title}", + url=issue_url, + description=f"Issue in `{repo_name}` was {action}.", + color=color + ) + embed.set_author(name=user_login, url=user_url, icon_url=user_avatar) + + if issue_data.get('body') and action == "opened": + body = issue_data['body'] + embed.add_field(name="Description", value=body[:1020] + "..." if len(body) > 1024 else body, inline=False) + + if issue_data.get('labels'): + labels = ", ".join([f"`{label['name']}`" for label in issue_data['labels']]) + embed.add_field(name="Labels", value=labels if labels else "None", inline=True) + + if issue_data.get('assignee'): + assignee = issue_data['assignee']['login'] + embed.add_field(name="Assignee", value=f"[{assignee}]({issue_data['assignee']['html_url']})", inline=True) + elif issue_data.get('assignees'): + assignees = ", ".join([f"[{a['login']}]({a['html_url']})" for a in issue_data['assignees']]) + embed.add_field(name="Assignees", value=assignees if assignees else "None", inline=True) + + return embed + except Exception as e: + log.error(f"Error formatting GitHub issues embed: {e}\nPayload: {payload}") + return discord.Embed(title="Error Processing GitHub Issue Event", description=str(e), color=discord.Color.red()) + +def format_github_pull_request_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed: + """Formats a Discord embed for a GitHub pull_request event.""" + try: + action = payload.get('action', 'Unknown action') + pr_data = payload.get('pull_request', {}) + repo_name = payload.get('repository', {}).get('full_name', repo_url) + sender = payload.get('sender', {}) + + title = pr_data.get('title', 'Untitled Pull Request') + pr_number = payload.get('number', pr_data.get('number')) # 'number' is top-level for some PR actions + pr_url = pr_data.get('html_url', repo_url) + user_login = sender.get('login', 'Unknown User') + user_url = sender.get('html_url', '#') + user_avatar = sender.get('avatar_url') + + color = discord.Color.green() if action == "opened" else \ + discord.Color.red() if action == "closed" and pr_data.get('merged') is False else \ + discord.Color.purple() if action == "closed" and pr_data.get('merged') is True else \ + discord.Color.gold() if action == "reopened" else \ + discord.Color.blue() if action in ["synchronize", "ready_for_review"] else \ + discord.Color.light_grey() + + description = f"Pull Request #{pr_number} in `{repo_name}` was {action}." + if action == "closed" and pr_data.get('merged'): + description = f"Pull Request #{pr_number} in `{repo_name}` was merged." + + embed = discord.Embed( + title=f"PR {action.capitalize()}: #{pr_number} {title}", + url=pr_url, + description=description, + color=color + ) + embed.set_author(name=user_login, url=user_url, icon_url=user_avatar) + + if pr_data.get('body') and action == "opened": + body = pr_data['body'] + embed.add_field(name="Description", value=body[:1020] + "..." if len(body) > 1024 else body, inline=False) + + embed.add_field(name="Base Branch", value=f"`{pr_data.get('base', {}).get('ref', 'N/A')}`", inline=True) + embed.add_field(name="Head Branch", value=f"`{pr_data.get('head', {}).get('ref', 'N/A')}`", inline=True) + + if action == "closed": + merged_by = pr_data.get('merged_by') + if merged_by: + embed.add_field(name="Merged By", value=f"[{merged_by['login']}]({merged_by['html_url']})", inline=True) + else: + embed.add_field(name="Status", value="Closed without merging", inline=True) + + + return embed + except Exception as e: + log.error(f"Error formatting GitHub PR embed: {e}\nPayload: {payload}") + return discord.Embed(title="Error Processing GitHub PR Event", description=str(e), color=discord.Color.red()) + +def format_github_release_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed: + """Formats a Discord embed for a GitHub release event.""" + try: + action = payload.get('action', 'Unknown action') # e.g., published, created, edited + release_data = payload.get('release', {}) + repo_name = payload.get('repository', {}).get('full_name', repo_url) + sender = payload.get('sender', {}) + + tag_name = release_data.get('tag_name', 'N/A') + release_name = release_data.get('name', tag_name) + release_url = release_data.get('html_url', repo_url) + user_login = sender.get('login', 'Unknown User') + user_url = sender.get('html_url', '#') + user_avatar = sender.get('avatar_url') + + color = discord.Color.teal() if action == "published" else discord.Color.blurple() + + embed = discord.Embed( + title=f"Release {action.capitalize()}: {release_name}", + url=release_url, + description=f"A new release `{tag_name}` was {action} in `{repo_name}`.", + color=color + ) + embed.set_author(name=user_login, url=user_url, icon_url=user_avatar) + + if release_data.get('body'): + body = release_data['body'] + embed.add_field(name="Release Notes", value=body[:1020] + "..." if len(body) > 1024 else body, inline=False) + + return embed + except Exception as e: + log.error(f"Error formatting GitHub release embed: {e}\nPayload: {payload}") + return discord.Embed(title="Error Processing GitHub Release Event", description=str(e), color=discord.Color.red()) + +def format_github_issue_comment_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed: + """Formats a Discord embed for a GitHub issue_comment event.""" + try: + action = payload.get('action', 'Unknown action') # created, edited, deleted + comment_data = payload.get('comment', {}) + issue_data = payload.get('issue', {}) + repo_name = payload.get('repository', {}).get('full_name', repo_url) + sender = payload.get('sender', {}) + + comment_url = comment_data.get('html_url', repo_url) + user_login = sender.get('login', 'Unknown User') + user_url = sender.get('html_url', '#') + user_avatar = sender.get('avatar_url') + + issue_title = issue_data.get('title', 'Untitled Issue') + issue_number = issue_data.get('number') + + color = discord.Color.greyple() + + embed = discord.Embed( + title=f"Comment {action} on Issue #{issue_number}: {issue_title}", + url=comment_url, + color=color + ) + embed.set_author(name=user_login, url=user_url, icon_url=user_avatar) + + if comment_data.get('body'): + body = comment_data['body'] + embed.description = body[:2040] + "..." if len(body) > 2048 else body + + return embed + except Exception as e: + log.error(f"Error formatting GitHub issue_comment embed: {e}\nPayload: {payload}") + return discord.Embed(title="Error Processing GitHub Issue Comment Event", description=str(e), color=discord.Color.red()) + +# --- GitLab - New Event Formatters --- + +def format_gitlab_issue_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed: + """Formats a Discord embed for a GitLab issue event (object_kind: 'issue').""" + try: + attributes = payload.get('object_attributes', {}) + user = payload.get('user', {}) + project_data = payload.get('project', {}) + repo_name = project_data.get('path_with_namespace', repo_url) + + action = attributes.get('action', 'unknown') # open, close, reopen, update + title = attributes.get('title', 'Untitled Issue') + issue_iid = attributes.get('iid') # Internal ID for display + issue_url = attributes.get('url', repo_url) + user_name = user.get('name', 'Unknown User') + user_avatar = user.get('avatar_url') + + color = discord.Color.green() if action == "open" else \ + discord.Color.red() if action == "close" else \ + discord.Color.gold() if action == "reopen" else \ + discord.Color.light_grey() + + embed = discord.Embed( + title=f"Issue {action.capitalize()}: #{issue_iid} {title}", + url=issue_url, + description=f"Issue in `{repo_name}` was {action}.", + color=color + ) + embed.set_author(name=user_name, icon_url=user_avatar) + + if attributes.get('description') and action == "open": + desc = attributes['description'] + embed.add_field(name="Description", value=desc[:1020] + "..." if len(desc) > 1024 else desc, inline=False) + + if attributes.get('labels'): + labels = ", ".join([f"`{label['title']}`" for label in attributes['labels']]) + embed.add_field(name="Labels", value=labels if labels else "None", inline=True) + + assignees_data = payload.get('assignees', []) + if assignees_data: + assignees = ", ".join([f"{a['name']}" for a in assignees_data]) + embed.add_field(name="Assignees", value=assignees, inline=True) + + return embed + except Exception as e: + log.error(f"Error formatting GitLab issue embed: {e}\nPayload: {payload}") + return discord.Embed(title="Error Processing GitLab Issue Event", description=str(e), color=discord.Color.red()) + +def format_gitlab_merge_request_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed: + """Formats a Discord embed for a GitLab merge_request event.""" + try: + attributes = payload.get('object_attributes', {}) + user = payload.get('user', {}) + project_data = payload.get('project', {}) + repo_name = project_data.get('path_with_namespace', repo_url) + + action = attributes.get('action', 'unknown') # open, close, reopen, update, merge + title = attributes.get('title', 'Untitled Merge Request') + mr_iid = attributes.get('iid') + mr_url = attributes.get('url', repo_url) + user_name = user.get('name', 'Unknown User') + user_avatar = user.get('avatar_url') + + color = discord.Color.green() if action == "open" else \ + discord.Color.red() if action == "close" else \ + discord.Color.purple() if action == "merge" else \ + discord.Color.gold() if action == "reopen" else \ + discord.Color.blue() if action == "update" else \ + discord.Color.light_grey() + + description = f"Merge Request !{mr_iid} in `{repo_name}` was {action}." + if action == "merge": + description = f"Merge Request !{mr_iid} in `{repo_name}` was merged." + + embed = discord.Embed( + title=f"MR {action.capitalize()}: !{mr_iid} {title}", + url=mr_url, + description=description, + color=color + ) + embed.set_author(name=user_name, icon_url=user_avatar) + + if attributes.get('description') and action == "open": + desc = attributes['description'] + embed.add_field(name="Description", value=desc[:1020] + "..." if len(desc) > 1024 else desc, inline=False) + + embed.add_field(name="Source Branch", value=f"`{attributes.get('source_branch', 'N/A')}`", inline=True) + embed.add_field(name="Target Branch", value=f"`{attributes.get('target_branch', 'N/A')}`", inline=True) + + if action == "merge" and attributes.get('merge_commit_sha'): + embed.add_field(name="Merge Commit", value=f"`{attributes['merge_commit_sha'][:8]}`", inline=True) + + return embed + except Exception as e: + log.error(f"Error formatting GitLab MR embed: {e}\nPayload: {payload}") + return discord.Embed(title="Error Processing GitLab MR Event", description=str(e), color=discord.Color.red()) + +def format_gitlab_release_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed: + """Formats a Discord embed for a GitLab release event.""" + try: + # GitLab release webhook payload structure is simpler + action = payload.get('action', 'created') # create, update + tag_name = payload.get('tag', 'N/A') + release_name = payload.get('name', tag_name) + release_url = payload.get('url', repo_url) + project_data = payload.get('project', {}) + repo_name = project_data.get('path_with_namespace', repo_url) + # GitLab release hooks don't typically include a 'user' who performed the action directly in the root. + # It might be inferred or logged differently by GitLab. For now, we'll omit a specific author. + + color = discord.Color.teal() if action == "create" else discord.Color.blurple() + + embed = discord.Embed( + title=f"Release {action.capitalize()}: {release_name}", + url=release_url, + description=f"A release `{tag_name}` was {action} in `{repo_name}`.", + color=color + ) + # embed.set_author(name=project_data.get('namespace', 'GitLab')) # Or project name + + if payload.get('description'): + desc = payload['description'] + embed.add_field(name="Release Notes", value=desc[:1020] + "..." if len(desc) > 1024 else desc, inline=False) + + return embed + except Exception as e: + log.error(f"Error formatting GitLab release embed: {e}\nPayload: {payload}") + return discord.Embed(title="Error Processing GitLab Release Event", description=str(e), color=discord.Color.red()) + +def format_gitlab_note_embed(payload: Dict[str, Any], repo_url: str) -> discord.Embed: + """Formats a Discord embed for a GitLab note event (comments).""" + try: + attributes = payload.get('object_attributes', {}) + user = payload.get('user', {}) + project_data = payload.get('project', {}) + repo_name = project_data.get('path_with_namespace', repo_url) + + note_type = attributes.get('noteable_type', 'Comment') # Issue, MergeRequest, Commit, Snippet + note_url = attributes.get('url', repo_url) + user_name = user.get('name', 'Unknown User') + user_avatar = user.get('avatar_url') + + title_prefix = "New Comment" + target_info = "" + + if note_type == 'Commit': + commit_data = payload.get('commit', {}) + title_prefix = f"Comment on Commit `{commit_data.get('id', 'N/A')[:7]}`" + elif note_type == 'Issue': + issue_data = payload.get('issue', {}) + title_prefix = f"Comment on Issue #{issue_data.get('iid', 'N/A')}" + target_info = issue_data.get('title', '') + elif note_type == 'MergeRequest': + mr_data = payload.get('merge_request', {}) + title_prefix = f"Comment on MR !{mr_data.get('iid', 'N/A')}" + target_info = mr_data.get('title', '') + elif note_type == 'Snippet': + snippet_data = payload.get('snippet', {}) + title_prefix = f"Comment on Snippet #{snippet_data.get('id', 'N/A')}" + target_info = snippet_data.get('title', '') + + embed = discord.Embed( + title=f"{title_prefix}: {target_info}".strip(), + url=note_url, + color=discord.Color.greyple() + ) + embed.set_author(name=user_name, icon_url=user_avatar) + + if attributes.get('note'): + note_body = attributes['note'] + embed.description = note_body[:2040] + "..." if len(note_body) > 2048 else note_body + + embed.set_footer(text=f"Comment in {repo_name}") + return embed + except Exception as e: + log.error(f"Error formatting GitLab note embed: {e}\nPayload: {payload}") + return discord.Embed(title="Error Processing GitLab Note Event", description=str(e), color=discord.Color.red()) + @router.post("/github/{repo_db_id}") async def webhook_github( @@ -280,18 +647,51 @@ async def webhook_github( 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."} + allowed_events = repo_config.get('allowed_webhook_events', ['push']) # Default to 'push' - 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."} + if event_type not in allowed_events: + log.info(f"Ignoring GitHub event type '{event_type}' for repo_db_id: {repo_db_id} as it's not in allowed events: {allowed_events}") + return {"status": "success", "message": f"Event type '{event_type}' ignored per configuration."} + + discord_embed = None + if event_type == "push": + if not payload.get('commits') and not payload.get('deleted', False): # Also consider branch deletion as a push event + log.info(f"GitHub push event for {repo_db_id} has no commits and is not a delete event. Ignoring.") + return {"status": "success", "message": "Push event with no commits ignored."} + discord_embed = format_github_push_embed(payload, repo_config['repository_url']) + elif event_type == "issues": + discord_embed = format_github_issues_embed(payload, repo_config['repository_url']) + elif event_type == "pull_request": + discord_embed = format_github_pull_request_embed(payload, repo_config['repository_url']) + elif event_type == "release": + discord_embed = format_github_release_embed(payload, repo_config['repository_url']) + elif event_type == "issue_comment": + discord_embed = format_github_issue_comment_embed(payload, repo_config['repository_url']) + # Add other specific event types above this else block + else: + log.info(f"GitHub event type '{event_type}' is allowed but not yet handled by a specific formatter for repo_db_id: {repo_db_id}. Sending generic message.") + # For unhandled but allowed events, send a generic notification or log. + # For now, we'll just acknowledge. If you want to notify for all allowed events, create generic formatter. + # return {"status": "success", "message": f"Event type '{event_type}' received but no specific formatter yet."} + # Or, create a generic embed: + embed_title = f"GitHub Event: {event_type.replace('_', ' ').title()} in {repo_config.get('repository_url')}" + embed_description = f"Received a '{event_type}' event." + # Try to get a relevant URL + action_url = payload.get('repository', {}).get('html_url', '#') + if event_type == 'issues' and 'issue' in payload and 'html_url' in payload['issue']: + action_url = payload['issue']['html_url'] + elif event_type == 'pull_request' and 'pull_request' in payload and 'html_url' in payload['pull_request']: + action_url = payload['pull_request']['html_url'] + + discord_embed = discord.Embed(title=embed_title, description=embed_description, url=action_url, color=discord.Color.light_grey()) + + + if not discord_embed: + log.warning(f"No embed generated for allowed GitHub event '{event_type}' for repo {repo_db_id}. This shouldn't happen if event is handled.") + return {"status": "error", "message": "Embed generation failed for an allowed event."} 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()]} @@ -384,18 +784,69 @@ async def webhook_gitlab( 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."} + # GitLab uses 'object_kind' for event type, or 'event_name' for system hooks + event_type = payload.get("object_kind", payload.get("event_name")) + allowed_events = repo_config.get('allowed_webhook_events', ['push']) # Default to 'push' (GitLab calls push hooks 'push events' or 'tag_push events') - 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."} + # Normalize GitLab event types if needed, e.g. 'push' for 'push_hook' or 'tag_push_hook' + # For now, assume direct match or that 'push' covers both. + # GitLab event names for webhooks: push_events, tag_push_events, issues_events, merge_requests_events, etc. + # The payload's object_kind is often more specific: 'push', 'tag_push', 'issue', 'merge_request'. + # We should aim to match against object_kind primarily. + # Let's simplify: if 'push' is in allowed_events, we'll accept 'push' and 'tag_push' object_kinds. + + effective_event_type = event_type + if event_type == "tag_push" and "push" in allowed_events and "tag_push" not in allowed_events: + # If only "push" is allowed, but we receive "tag_push", treat it as a push for now. + # This logic might need refinement based on how granular the user wants control. + pass # It will be caught by the 'push' check if 'push' is allowed. + + is_event_allowed = False + if event_type in allowed_events: + is_event_allowed = True + elif event_type == "tag_push" and "push" in allowed_events: # Special handling if 'push' implies 'tag_push' + is_event_allowed = True + effective_event_type = "push" # Treat as push for formatter if only push is configured + + if not is_event_allowed: + log.info(f"Ignoring GitLab event type '{event_type}' (object_kind/event_name) for repo_db_id: {repo_db_id} as it's not in allowed events: {allowed_events}") + return {"status": "success", "message": f"Event type '{event_type}' ignored per configuration."} + + discord_embed = None + # Use effective_event_type for choosing formatter + if effective_event_type == "push": # This will catch 'push' and 'tag_push' if 'push' is allowed + if not payload.get('commits') and payload.get('total_commits_count', 0) == 0: + log.info(f"GitLab push event for {repo_db_id} has no commits. Ignoring.") + return {"status": "success", "message": "Push event with no commits ignored."} + discord_embed = format_gitlab_push_embed(payload, repo_config['repository_url']) + elif effective_event_type == "issue": # Matches object_kind 'issue' + discord_embed = format_gitlab_issue_embed(payload, repo_config['repository_url']) + elif effective_event_type == "merge_request": + discord_embed = format_gitlab_merge_request_embed(payload, repo_config['repository_url']) + elif effective_event_type == "release": + discord_embed = format_gitlab_release_embed(payload, repo_config['repository_url']) + elif effective_event_type == "note": # For comments + discord_embed = format_gitlab_note_embed(payload, repo_config['repository_url']) + # Add other specific event types above this else block + else: + log.info(f"GitLab event type '{event_type}' (effective: {effective_event_type}) is allowed but not yet handled by a specific formatter for repo_db_id: {repo_db_id}. Sending generic message.") + embed_title = f"GitLab Event: {event_type.replace('_', ' ').title()} in {repo_config.get('repository_url')}" + embed_description = f"Received a '{event_type}' event." + action_url = payload.get('project', {}).get('web_url', '#') + # Try to get more specific URLs for common GitLab events + if 'object_attributes' in payload and 'url' in payload['object_attributes']: + action_url = payload['object_attributes']['url'] + elif 'project' in payload and 'web_url' in payload['project']: + action_url = payload['project']['web_url'] + + discord_embed = discord.Embed(title=embed_title, description=embed_description, url=action_url, color=discord.Color.dark_orange()) + + + if not discord_embed: + log.warning(f"No embed generated for allowed GitLab event '{event_type}' for repo {repo_db_id}.") + return {"status": "error", "message": "Embed generation failed for an allowed event."} 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: @@ -516,4 +967,4 @@ async def test_db_connection(request: Request): return { "message": "Unexpected error in test-db endpoint", "error": str(e) - } \ No newline at end of file + } diff --git a/settings_manager.py b/settings_manager.py index f1fcd7d..993ce87 100644 --- a/settings_manager.py +++ b/settings_manager.py @@ -243,6 +243,7 @@ async def initialize_database(): 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, + allowed_webhook_events TEXT[] DEFAULT ARRAY['push']::TEXT[], -- Stores which webhook events to notify for CONSTRAINT uq_guild_repo_channel UNIQUE (guild_id, repository_url, notification_channel_id), FOREIGN KEY (guild_id) REFERENCES guilds(guild_id) ON DELETE CASCADE ); @@ -252,6 +253,30 @@ async def initialize_database(): 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);") + # Migration: Add allowed_webhook_events column if it doesn't exist and set default for old rows + column_exists_git_events = await conn.fetchval(""" + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'git_monitored_repositories' + AND column_name = 'allowed_webhook_events' + ); + """) + if not column_exists_git_events: + log.info("Adding allowed_webhook_events column to git_monitored_repositories table...") + await conn.execute(""" + ALTER TABLE git_monitored_repositories + ADD COLUMN allowed_webhook_events TEXT[] DEFAULT ARRAY['push']::TEXT[]; + """) + # Update existing rows to have a default value if they are NULL + await conn.execute(""" + UPDATE git_monitored_repositories + SET allowed_webhook_events = ARRAY['push']::TEXT[] + WHERE allowed_webhook_events IS NULL; + """) + log.info("Added allowed_webhook_events column and set default for existing rows.") + else: + log.debug("allowed_webhook_events column already exists in git_monitored_repositories table.") # Logging Event Toggles table - Stores enabled/disabled state per event type await conn.execute(""" @@ -2194,7 +2219,8 @@ async def add_monitored_repository( 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 + last_polled_commit_sha: str | None = None, # For initial poll setup + allowed_webhook_events: list[str] | None = None # List of event names like ['push', 'issues'] ) -> int | None: """Adds a new repository to monitor. Returns the ID of the new row, or None on failure.""" bot = get_bot_instance() @@ -2208,23 +2234,28 @@ async def add_monitored_repository( await conn.execute("INSERT INTO guilds (guild_id) VALUES ($1) ON CONFLICT (guild_id) DO NOTHING;", guild_id) # Insert the new repository monitoring entry + # Default allowed_webhook_events if not provided or empty + final_allowed_events = allowed_webhook_events if allowed_webhook_events else ['push'] + 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 + polling_interval_minutes, is_public_repo, last_polled_commit_sha, + allowed_webhook_events ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) 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 + polling_interval_minutes, is_public_repo, last_polled_commit_sha, + final_allowed_events ) 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}") + log.info(f"Added repository '{repository_url}' (Branch: {target_branch or 'default'}, Events: {final_allowed_events}) 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( @@ -2251,10 +2282,10 @@ async def get_monitored_repository_by_id(repo_db_id: int) -> Dict | None: try: async with bot.pg_pool.acquire() as conn: record = await conn.fetchrow( - "SELECT * FROM git_monitored_repositories WHERE id = $1", + "SELECT *, allowed_webhook_events FROM git_monitored_repositories WHERE id = $1", # Ensure new column is fetched repo_db_id ) - print(f"Grep this line: {dict(record)}") + # log.info(f"Grep this line: {dict(record) if record else 'No record found'}") # Keep for debugging if needed 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}") @@ -2270,7 +2301,7 @@ async def get_monitored_repository_by_url(guild_id: int, repository_url: str, no async with bot.pg_pool.acquire() as conn: record = await conn.fetchrow( """ - SELECT * FROM git_monitored_repositories + SELECT *, allowed_webhook_events FROM git_monitored_repositories WHERE guild_id = $1 AND repository_url = $2 AND notification_channel_id = $3 """, guild_id, repository_url, notification_channel_id @@ -2280,6 +2311,28 @@ async def get_monitored_repository_by_url(guild_id: int, repository_url: str, no log.exception(f"Database error getting monitored repository by URL '{repository_url}' for guild {guild_id}: {e}") return None +async def update_monitored_repository_events(repo_db_id: int, allowed_events: list[str]) -> bool: + """Updates the allowed webhook events for a specific monitored 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_monitored_repository_events (ID {repo_db_id}).") + return False + try: + async with bot.pg_pool.acquire() as conn: + await conn.execute( + """ + UPDATE git_monitored_repositories + SET allowed_webhook_events = $2 + WHERE id = $1; + """, + repo_db_id, allowed_events + ) + log.info(f"Updated allowed webhook events for repository ID {repo_db_id} to {allowed_events}.") + # Consider cache invalidation here if caching these lists directly per repo_id + return True + except Exception as e: + log.exception(f"Database error updating allowed webhook events for repository ID {repo_db_id}: {e}") + return False 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."""